2026-08-04 00:23:55 +08:00
<!doctype html>
2026-08-04 11:08:41 +08:00
< html lang = "zh-CN" >
2026-08-04 00:23:55 +08:00
< head >
< meta charset = "utf-8" >
2026-08-04 11:08:41 +08:00
< meta name = "viewport" content = "width=device-width,initial-scale=1,viewport-fit=cover" >
< title > YoVision Sense · 接入运维工作台原型</ title >
2026-08-04 00:23:55 +08:00
< style >
2026-08-04 11:08:41 +08:00
: root { color-scheme : dark ; --bg : #07101d ; --surface : #0d1928 ; --surface2 : #122235 ; --line : #294057 ; --text : #eff7ff ; --muted : #9fb2c6 ; --primary : #55d6be ; --primary2 : #113e3b ; --info : #69b9ff ; --warn : #ffc462 ; --danger : #ff8290 ; --success : #67d69b ; --shadow : 0 18 px 55 px rgba ( 0 , 0 , 0 , .32 ); font-family : Inter , "Segoe UI" , "Microsoft YaHei" , sans-serif }
* { box-sizing : border-box } html { background : var ( -- bg )} body { margin : 0 ; background : radial-gradient ( circle at 70 % 0 , #112941 0 , transparent 38 % ), var ( -- bg ); color : var ( -- text ); font-size : 14 px ; min-height : 100 vh } button , input , select , textarea { font : inherit } button , a , select , input { touch-action : manipulation } button : focus-visible , a : focus-visible , input : focus-visible , select : focus-visible , textarea : focus-visible ,[ tabindex ] : focus-visible { outline : 3 px solid #9ce7ff ; outline-offset : 2 px } [ hidden ] { display : none !important }. prototype { position : sticky ; top : 0 ; z-index : 100 ; height : 28 px ; display : grid ; place-items : center ; background : #ffd66b ; color : #241d00 ; font-weight : 800 ; font-size : 12 px ; letter-spacing : .04 em }. shell { min-height : calc ( 100 vh - 28 px ); display : grid ; grid-template-columns : 240 px minmax ( 0 , 1 fr )}. sidebar { position : sticky ; top : 28 px ; height : calc ( 100 vh - 28 px ); padding : 18 px 14 px ; border-right : 1 px solid var ( -- line ); background : rgba ( 8 , 18 , 31 , .92 ); backdrop-filter : blur ( 14 px )}. brand { display : flex ; align-items : center ; gap : 10 px ; padding : 4 px 8 px 22 px }. brand-mark { width : 36 px ; height : 36 px ; border-radius : 11 px ; display : grid ; place-items : center ; background : linear-gradient ( 135 deg , var ( -- primary ), #5c9dff ); color : #06151b ; font-weight : 900 }. brand strong { display : block ; font-size : 17 px }. brand small { color : var ( -- muted )}. nav { display : grid ; gap : 6 px }. nav button , . mobile-nav button { border : 0 ; cursor : pointer }. nav button { min-height : 46 px ; padding : 0 12 px ; border-radius : 10 px ; background : transparent ; color : var ( -- muted ); display : flex ; align-items : center ; gap : 10 px ; text-align : left }. nav button : hover , . nav button . active { background : var ( -- primary2 ); color : var ( -- text )}. nav button . active { box-shadow : inset 3 px 0 var ( -- primary )}. count { margin-left : auto ; min-width : 22 px ; padding : 2 px 7 px ; border-radius : 999 px ; background : #263d50 ; color : var ( -- text ); font-size : 12 px ; text-align : center }. side-note { position : absolute ; left : 14 px ; right : 14 px ; bottom : 18 px ; padding : 12 px ; border : 1 px solid var ( -- line ); border-radius : 11 px ; color : var ( -- muted ); font-size : 12 px ; line-height : 1.55 }. main { min-width : 0 }. topbar { min-height : 70 px ; padding : 12 px 24 px ; border-bottom : 1 px solid var ( -- line ); display : flex ; align-items : center ; justify-content : space-between ; gap : 16 px ; background : rgba ( 8 , 18 , 31 , .72 ); backdrop-filter : blur ( 10 px )}. context , . top-actions , . actions , . inline { display : flex ; align-items : center ; gap : 10 px ; flex-wrap : wrap }. context-block { display : grid ; gap : 3 px }. eyebrow { font-size : 11 px ; text-transform : uppercase ; letter-spacing : .08 em ; color : var ( -- muted )}. context strong { font-size : 15 px }. select , . input , . textarea { min-height : 42 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : #091726 ; color : var ( -- text ); padding : 0 11 px }. textarea { padding : 10 px ; min-height : 82 px ; resize : vertical }. select : disabled , . input : disabled , . btn : disabled { opacity : .5 ; cursor : not-allowed }. content { padding : 24 px ; max-width : 1540 px ; margin : auto }. view { display : none }. view . active { display : block }. page-head { display : flex ; align-items : flex-end ; justify-content : space-between ; gap : 16 px ; margin-bottom : 20 px }. page-head h1 { font-size : 25 px ; margin : 0 0 6 px }. page-head p , . muted { color : var ( -- muted ); margin : 0 }. btn { min-height : 42 px ; padding : 0 14 px ; border : 1 px solid var ( -- line ); border-radius : 9 px ; background : #15283b ; color : var ( -- text ); cursor : pointer ; text-decoration : none ; display : inline-flex ; align-items : center ; justify-content : center ; gap : 7 px }. btn : hover { border-color : #5b809f }. btn-primary { background : var ( -- primary ); border-color : var ( -- primary ); color : #06221e ; font-weight : 800 }. btn-danger { background : #51232d ; border-color : #86414d ; color : #fff }. btn-quiet { background : transparent }. btn-sm { min-height : 36 px ; padding : 0 10 px ; font-size : 12 px }. icon-btn { width : 42 px ; padding : 0 ; font-size : 20 px }. grid { display : grid ; gap : 14 px }. stats { grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr )); margin-bottom : 14 px }. card { border : 1 px solid var ( -- line ); border-radius : 14 px ; background : linear-gradient ( 150 deg , rgba ( 18 , 34 , 53 , .96 ), rgba ( 10 , 24 , 39 , .96 )); box-shadow : var ( -- shadow )} button . card { color : inherit ; text-align : left ; cursor : pointer }. stat { padding : 17 px }. stat b { display : block ; font-size : 27 px ; margin : 8 px 0 4 px }. stat small { color : var ( -- muted )}. cols { grid-template-columns : minmax ( 0 , 1.55 fr ) minmax ( 300 px , .8 fr )}. card-head { padding : 15 px 17 px ; border-bottom : 1 px solid var ( -- line ); display : flex ; justif
@media( max-width : 1100 px ) { . stats , . ops-summary { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. monitor-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. cap-grid , . system-grid { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. kv { grid-template-columns : repeat ( 2 , minmax ( 0 , 1 fr ))}. cols { grid-template-columns : 1 fr } }
@ media ( max-width : 850px ) { body { padding-bottom : 74 px }. shell { display : block }. sidebar { display : none }. topbar { padding : 10 px 12 px ; align-items : flex-start }. context { gap : 7 px }. context-block : first-child , . user-label , . manage-link { display : none }. top-actions { justify-content : flex-end }. content { padding : 15 px 12 px }. page-head { align-items : flex-start ; flex-direction : column }. stats , . ops-summary , . monitor-grid , . cap-grid , . system-grid , . kv , . zone-layout { grid-template-columns : 1 fr }. filters { grid-template-columns : 1 fr 1 fr }. filters . input { grid-column : 1 /- 1 }. detail-summary { align-items : flex-start ; flex-direction : column }. btn , . btn-sm , . select , . input , . textarea , . link-btn { min-height : 44 px }. btn , . link-btn { min-width : 44 px }. link-btn { display : inline-flex ; align-items : center }. mobile-nav { position : fixed ; z-index : 70 ; display : grid ; grid-template-columns : repeat ( 5 , 1 fr ); left : 7 px ; right : 7 px ; bottom : 7 px ; padding : 5 px ; border : 1 px solid var ( -- line ); border-radius : 13 px ; background : #0c1928 ; box-shadow : var ( -- shadow )}. mobile-nav button { min-height : 50 px ; border-radius : 8 px ; background : transparent ; color : var ( -- muted ); font-size : 11 px }. mobile-nav button . active { background : var ( -- primary2 ); color : var ( -- text )}. state-overlay { inset : 98 px 0 0 }. form-grid { grid-template-columns : 1 fr }. field . full { grid-column : auto }. banner { align-items : flex-start ; flex-direction : column }. table { min-width : 720 px }}
@ media ( prefers-reduced-motion : reduce ) { *,* : before ,* : after { scroll-behavior : auto !important ; animation : none !important ; transition : none !important }}
2026-08-04 09:07:18 +08:00
</ style >
</ head >
< body >
2026-08-04 11:08:41 +08:00
< div class = "prototype" > PROTOTYPE - 仅供枚举交互,非实现依据</ div >
< svg width = "0" height = "0" aria-hidden = "true" >< symbol id = "logo" viewBox = "0 0 24 24" >< path fill = "currentColor" d = "M3 5h18v4H3zm0 6h11v8H3zm13 0h5v8h-5z" /></ symbol ></ svg >
< div class = "shell" >
< aside class = "sidebar" >
< div class = "brand" >< span class = "brand-mark" > S</ span >< div >< strong > YoVision Sense</ strong >< small > 接入运维工作台</ small ></ div ></ div >
< nav class = "nav" aria-label = "Sense 一级导航" >
< button data-view = "overview" >< span > ◫</ span >< span > 运行总览</ span ></ button >
< button data-view = "monitor" >< span > ▶</ span >< span > 实时监控</ span >< span class = "count" > 4</ span ></ button >
< button data-view = "devices" class = "active" aria-current = "page" >< span > ▣</ span >< span > 设备</ span >< span class = "count" > 17</ span ></ button >
< button data-view = "onboarding" >< span > ⇧</ span >< span > 接入任务</ span >< span class = "count" > 2</ span ></ button >
< button data-view = "operations" >< span > ⌁</ span >< span > 运维中心</ span >< span class = "count" > 4</ span ></ button >
2026-08-04 09:07:18 +08:00
</ nav >
2026-08-04 11:08:41 +08:00
< div class = "side-note" > 服务器时间:Asia/Shanghai< br >< span id = "serverClock" > 2026-08-04 10:48:22</ span >< br > Area/配额投影:v42 · 2 分钟前</ div >
2026-08-04 09:07:18 +08:00
</ aside >
< main class = "main" id = "main-content" tabindex = "-1" >
< header class = "topbar" >
2026-08-04 11:08:41 +08:00
< div class = "context" >
< div class = "context-block" >< span class = "eyebrow" > 租户</ span >< strong > 青藤教育集团</ strong ></ div >
< div class = "context-block" >< label class = "eyebrow" for = "siteSwitcher" > 当前站点</ label >< select class = "select" id = "siteSwitcher" >< option value = "ivy" > 青藤寄宿学校</ option >< option value = "east" > 城东试验点</ option ></ select ></ div >
< a class = "btn btn-quiet manage-link" href = "../bell/index.html?view=sites&tenant=tenant-ivy&site=site-ivy&return_to=sense" aria-label = "到 Bell 管理站点与区域" > 管理站点 / Area ↗</ a >
</ div >
2026-08-04 09:07:18 +08:00
< div class = "top-actions" >
2026-08-04 11:08:41 +08:00
< label class = "eyebrow" for = "roleSwitcher" > 演示角色</ label >< select class = "select" id = "roleSwitcher" >< option value = "admin" > 站点管理员</ option >< option value = "readonly" > 只读用户</ option ></ select >
< label class = "eyebrow" for = "stateSelect" > 页面状态</ label >< select class = "select" id = "stateSelect" >< option value = "normal" > 正常</ option >< option value = "loading" > 加载中</ option >< option value = "empty" > 空数据</ option >< option value = "error" > 读取失败</ option >< option value = "quota_unavailable" > 配额不可用</ option >< option value = "policy_unavailable" > Area 策略不可用</ option >< option value = "denied" > 无权限</ option ></ select >
< span class = "user-label" > 林工</ span >
2026-08-04 09:07:18 +08:00
</ div >
</ header >
< div class = "content" >
2026-08-04 11:08:41 +08:00
< div id = "degradeBanner" class = "banner" hidden >< div >< strong id = "degradeTitle" > 依赖不可用</ strong >< div id = "degradeText" > 已有设备与视频继续运行;只阻止相关新增或启用操作。</ div ></ div >< button class = "btn btn-sm" id = "retryDependency" > 重新检查</ button ></ div >
2026-08-04 09:07:18 +08:00
< section class = "view" data-page = "overview" >
2026-08-04 11:08:41 +08:00
< div class = "page-head" >< div >< h1 > 运行总览</ h1 >< p > 先看影响范围,再进入设备或运维中心处理。</ p ></ div >< button class = "btn btn-primary" data-write-action data-open-add > 添加设备</ button ></ div >
2026-08-04 09:07:18 +08:00
< div class = "grid stats" >
2026-08-04 11:08:41 +08:00
< button class = "card stat" data-jump = "devices" >< span class = "muted" > 设备台账</ span >< b > 17</ b >< small > 视频 16 · 雷达 1(适配器未就绪)</ small ></ button >
< button class = "card stat" data-jump = "monitor" >< span class = "muted" > 当前 Readers</ span >< b > 15</ b >< small > 并发预览上限 4 路</ small ></ button >
< button class = "card stat" data-jump = "operations" data-op-tab = "reconcile" >< span class = "muted" > 待收敛</ span >< b > 2</ b >< small > 媒体 1 · 推理 1</ small ></ button >
< button class = "card stat" data-jump = "operations" data-op-tab = "alerts" >< span class = "muted" > 运维告警</ span >< b > 4</ b >< small > 与 Bell 业务预警分离</ small ></ button >
2026-08-04 09:07:18 +08:00
</ div >
2026-08-04 11:08:41 +08:00
< div class = "grid cols" >
< article class = "card" >< div class = "card-head" >< h2 > 需要关注</ h2 >< button class = "btn btn-sm" data-jump = "operations" > 查看全部</ button ></ div >< div class = "card-body list" >< div class = "list-item" >< div >< strong > 北门 02 · 认证失败</ strong >< span class = "muted" > 旧凭据不会回显,等待管理员更新</ span ></ div >< span class = "badge danger" > 阻断接入</ span ></ div >< div class = "list-item" >< div >< strong > 宿舍东侧 03 · 推理未收敛</ strong >< span class = "muted" > 退避第 2 次 · 下次 10:49:10</ span ></ div >< span class = "badge warn" > 待重试</ span ></ div >< div class = "list-item" >< div >< strong > edge-east · 隧道断开</ strong >< span class = "muted" > 视频数据面仍正常 · 补传队列 86 MB</ span ></ div >< span class = "badge warn" > 控制面异常</ span ></ div ></ div ></ article >
< article class = "card" >< div class = "card-head" >< h2 > 容量</ h2 ></ div >< div class = "card-body" >< p >< strong > 视频 16 / 128</ strong ></ p >< div class = "task-progress" >< i style = "width:12.5%" ></ i ></ div >< p class = "muted" > 默认交付 16,不是硬编码上限。当前媒体分片承载 16 / 初始配置建议 32,启用 1 / 最多 4。</ p >< button class = "btn btn-sm" data-jump = "operations" data-op-tab = "shards" > 查看分片依据</ button ></ div ></ article >
2026-08-04 09:07:18 +08:00
</ div >
</ section >
< section class = "view" data-page = "monitor" >
2026-08-04 11:08:41 +08:00
< div class = "page-head" >< div >< h1 > 实时监控</ h1 >< p > 默认仅 4 路低码率预览;128 路不同时加载。</ p ></ div >< div class = "actions" >< select class = "select" aria-label = "监控布局" >< option > 2 × 2</ option >< option > 1 + 3</ option ></ select >< button class = "btn" data-write-action > 保存布局</ button ></ div ></ div >
< div class = "grid monitor-grid" >< article class = "card video-card" >< div class = "video" > 低码率预览</ div >< div class = "card-body" >< strong > 北围墙 01</ strong >< p class = "muted" > 在线 · reader 2</ p ></ div ></ article >< article class = "card video-card" >< div class = "video" > 低码率预览</ div >< div class = "card-body" >< strong > 北围墙 02</ strong >< p class = "muted" > 在线 · reader 1</ p ></ div ></ article >< article class = "card video-card" >< div class = "video" > 认证失败</ div >< div class = "card-body" >< strong > 北门 02</ strong >< p >< span class = "badge danger" > 无画面</ span ></ p ></ div ></ article >< article class = "card video-card" >< div class = "video" > 待激活</ div >< div class = "card-body" >< strong > 食堂后门 04</ strong >< p >< span class = "badge warn" > 尚未接入</ span ></ p ></ div ></ article ></ div >
2026-08-04 09:07:18 +08:00
</ section >
< section class = "view active" data-page = "devices" >
2026-08-04 11:08:41 +08:00
< div class = "page-head" >< div >< h1 > 设备</ h1 >< p >< span id = "siteName" > 青藤寄宿学校</ span > · 统一台账按模态与能力展示。</ p ></ div >< div class = "actions" >< button class = "btn" data-jump = "onboarding" > 批量接入</ button >< button class = "btn btn-primary" data-write-action data-open-add > 添加设备</ button ></ div ></ div >
< article class = "card" >< div class = "filters" >< label class = "field" >< span class = "eyebrow" > 搜索</ span >< input class = "input" id = "deviceSearch" placeholder = "名称 / 序列号" ></ label >< label class = "field" >< span class = "eyebrow" > 模态</ span >< select class = "select" id = "modalityFilter" >< option value = "all" > 全部模态</ option >< option value = "video" > 视频</ option >< option value = "radar" > 雷达</ option ></ select ></ label >< label class = "field" >< span class = "eyebrow" > 状态</ span >< select class = "select" id = "statusFilter" >< option value = "all" > 全部状态</ option >< option value = "online" > 在线</ option >< option value = "pending" > 待激活</ option >< option value = "auth_failed" > 认证失败</ option >< option value = "offline" > 离线</ option >< option value = "adapter_not_ready" > 适配器未就绪</ option ></ select ></ label >< button class = "btn" id = "clearFilters" > 清除筛选</ button ></ div >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 设备</ th >< th > 模态 / 能力</ th >< th > 状态</ th >< th > 期望 / 实际</ th >< th > Area</ th >< th > 最后变化</ th >< th > 操作</ th ></ tr ></ thead >< tbody id = "deviceRows" ></ tbody ></ table ></ div >< div class = "pager" >< span id = "pageSummary" > 第 1 / 2 页</ span >< div class = "actions" >< button class = "btn btn-sm" id = "prevPage" > 上一页</ button >< button class = "btn btn-sm" id = "nextPage" > 下一页</ button ></ div ></ div ></ article >
2026-08-04 09:07:18 +08:00
</ section >
< section class = "view" data-page = "device-detail" >
2026-08-04 11:08:41 +08:00
< button class = "btn btn-quiet" id = "backToDevices" > ← 返回设备</ button >
< article class = "card detail-summary" >< div >< span class = "eyebrow" id = "detailModality" > 视频设备</ span >< h1 id = "detailName" style = "margin:5px 0" > 北围墙 01</ h1 >< div class = "inline" >< span id = "detailStatus" class = "badge success" > 在线</ span >< span class = "muted" id = "detailSerial" > SN-HK-001</ span ></ div ></ div >< div class = "actions" id = "detailActions" >< button class = "btn" id = "auditLink" > 操作日志 ↗</ button >< button class = "btn" data-write-action id = "credentialBtn" > 更新凭据</ button >< button class = "btn" data-write-action id = "profileBtn" > 切换 Profile</ button >< button class = "btn btn-danger" data-write-action id = "lifecycleBtn" > 接入控制</ button ></ div ></ article >
< article class = "card" >< div class = "tabs" role = "tablist" aria-label = "设备详情" >< button role = "tab" id = "tab-summary" aria-controls = "panel-summary" aria-selected = "true" tabindex = "0" > 概览</ button >< button role = "tab" id = "tab-capabilities" aria-controls = "panel-capabilities" aria-selected = "false" tabindex = "-1" > 能力探测</ button >< button role = "tab" id = "tab-health" aria-controls = "panel-health" aria-selected = "false" tabindex = "-1" > 连接与健康</ button >< button role = "tab" id = "tab-zones" aria-controls = "panel-zones" aria-selected = "false" tabindex = "-1" data-video-only > 画面与检测区域</ button ></ div >
< section class = "tabpanel" role = "tabpanel" id = "panel-summary" aria-labelledby = "tab-summary" >< div class = "kv" id = "summaryKv" ></ div >< h2 class = "section-title" > 生命周期</ h2 >< div class = "list" id = "lifecycleList" ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "panel-capabilities" aria-labelledby = "tab-capabilities" hidden >< div class = "actions" style = "justify-content:space-between" >< p class = "muted" > 最近探测:< time title = "2026-08-04 10:42:18 +08:00" > 6 分钟前</ time > · 来源 ONVIF probe-8421</ p >< button class = "btn" data-write-action id = "reprobeBtn" > 重新探测并比较</ button ></ div >< div class = "grid cap-grid" id = "capabilityGrid" ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "panel-health" aria-labelledby = "tab-health" hidden >< div class = "grid system-grid" >< article class = "cap" >< strong > 连接</ strong >< p id = "healthConnection" > 媒体在线</ p >< p > 重连 0 次 · RTT 12ms</ p ></ article >< article class = "cap" >< strong > 设备时间</ strong >< p > 漂移 +0.2s</ p >< p > 最近校时 < time title = "2026-08-04 10:43:03 +08:00" > 5 分钟前</ time ></ p ></ article >< article class = "cap" >< strong > 下游实际态</ strong >< p > MediaMTX: running</ p >< p > Brain: subscribed · Storage: ready</ p ></ article ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "panel-zones" aria-labelledby = "tab-zones" hidden >< div class = "grid zone-layout" >< div class = "zone-canvas" id = "zoneCanvas" aria-label = "检测区域示意画布" >< svg viewBox = "0 0 100 100" preserveAspectRatio = "none" >< polygon id = "zonePolygon" points = "18,72 38,34 74,42 82,78" fill = "rgba(85,214,190,.18)" stroke = "#55d6be" stroke-width = "1.2" vector-effect = "non-scaling-stroke" />< polyline id = "zoneLine" points = "" fill = "none" stroke = "#ffc462" stroke-width = "1.4" /></ svg ></ div >< div class = "zone-tools" >< div >< strong > 危险区域 A · v7</ strong >< p class = "muted" > 归一化坐标;与 Bell 规则版本分离。</ p ></ div >< div id = "vertexList" ></ div >< div class = "actions" >< button class = "btn btn-sm" data-write-action id = "addVertex" > 添加点</ button >< button class = "btn btn-sm" data-write-action id = "reverseZone" > 反转方向</ button >< button class = "btn btn-sm" data-write-action id = "finishZone" > 完成绘制</ button ></ div >< button class = "btn btn-primary" data-write-action id = "saveZone" > 保存区域草稿</ button >< a class = "btn" id = "bellRuleLink" href = "../bell/index.html?view=rules&camera=dev-001&zone=zone-a&return_to=sense" > 到 Bell 配置业务规则 ↗</ a >< p class = "muted" id = "draftState" > 草稿已自动保存到当前会话</ p ></ div ></ div ></ section >
</ article >
2026-08-04 00:23:55 +08:00
</ section >
< section class = "view" data-page = "onboarding" >
2026-08-04 11:08:41 +08:00
< div class = "page-head" >< div >< h1 > 接入任务</ h1 >< p > CSV 导入、发现与逐项激活均保留可重试结果。</ p ></ div >< div class = "actions" >< button class = "btn" > 下载模板</ button >< button class = "btn btn-primary" data-write-action id = "newImport" > 新建批量导入</ button ></ div ></ div >
< div class = "grid stats" >< article class = "card stat" >< span class = "muted" > 运行中</ span >< b > 1</ b >< small > 发现与探测</ small ></ article >< article class = "card stat" >< span class = "muted" > 待激活</ span >< b > 2</ b >< small > 写入台账但尚未接入</ small ></ article >< article class = "card stat" >< span class = "muted" > 部分成功</ span >< b > 1</ b >< small > 可仅重试失败项</ small ></ article >< article class = "card stat" >< span class = "muted" > 过去 24h</ span >< b > 16</ b >< small > 成功激活</ small ></ article ></ div >
< article class = "card" >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 任务</ th >< th > 方式</ th >< th > 进度</ th >< th > 逐项结果</ th >< th > 操作者 / 时间</ th >< th > 操作</ th ></ tr ></ thead >< tbody >< tr >< td >< strong > 宿舍楼补点</ strong >< br >< span class = "muted" > task-20260804-17</ span ></ td >< td > CSV</ td >< td >< span class = "badge warn" > 部分成功</ span ></ td >< td > 成功 3 · 失败 1 · 待激活 1</ td >< td > 林工< br >< time class = "muted" title = "2026-08-04 10:31:08 +08:00" > 17 分钟前</ time ></ td >< td >< button class = "btn btn-sm" data-task-result > 查看结果</ button ></ td ></ tr >< tr >< td >< strong > 北区自动发现</ strong >< br >< span class = "muted" > task-20260804-18</ span ></ td >< td > ONVIF 发现</ td >< td >< div class = "task-progress" >< i style = "width:64%" ></ i ></ div ></ td >< td > 探测 7 / 11</ td >< td > 系统< br >< time class = "muted" title = "2026-08-04 10:46:22 +08:00" > 2 分钟前</ time ></ td >< td >< button class = "btn btn-sm" > 后台继续</ button ></ td ></ tr ></ tbody ></ table ></ div ></ article >
2026-08-04 00:23:55 +08:00
</ section >
2026-08-04 11:08:41 +08:00
< section class = "view" data-page = "operations" >
< div class = "page-head" >< div >< h1 > 运维中心</ h1 >< p > 接入控制面的差异与基础设施告警;不包含 Bell 业务预警。</ p ></ div >< button class = "btn" id = "refreshOps" > 刷新实际态</ button ></ div >
< div class = "grid ops-summary" >< article class = "card stat" >< span class = "muted" > 待收敛</ span >< b > 2</ b >< small > 最高优先级 90</ small ></ article >< article class = "card stat" >< span class = "muted" > 媒体分片</ span >< b > 1 / 4</ b >< small > 当前承载 16 / 配置建议 32</ small ></ article >< article class = "card stat" >< span class = "muted" > 边缘节点</ span >< b > 2 / 3</ b >< small > 1 个控制面隧道断开</ small ></ article >< article class = "card stat" >< span class = "muted" > 运维告警</ span >< b > 4</ b >< small > P1 1 · P2 3</ small ></ article ></ div >
< article class = "card" >< div class = "tabs ops-tabs" role = "tablist" aria-label = "运维中心分类" >< button role = "tab" id = "op-tab-reconcile" aria-controls = "op-panel-reconcile" aria-selected = "true" tabindex = "0" data-ops-tab = "reconcile" > 对账</ button >< button role = "tab" id = "op-tab-shards" aria-controls = "op-panel-shards" aria-selected = "false" tabindex = "-1" data-ops-tab = "shards" > 分片</ button >< button role = "tab" id = "op-tab-edge" aria-controls = "op-panel-edge" aria-selected = "false" tabindex = "-1" data-ops-tab = "edge" > 边缘与隧道</ button >< button role = "tab" id = "op-tab-alerts" aria-controls = "op-panel-alerts" aria-selected = "false" tabindex = "-1" data-ops-tab = "alerts" > 运维告警</ button >< button role = "tab" id = "op-tab-status" aria-controls = "op-panel-status" aria-selected = "false" tabindex = "-1" data-ops-tab = "status" > 系统状态</ button ></ div >
< section class = "tabpanel" role = "tabpanel" id = "op-panel-reconcile" aria-labelledby = "op-tab-reconcile" >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 对象 / 优先级</ th >< th > 期望态</ th >< th > 实际态</ th >< th > 差异与退避</ th >< th > 下次重试</ th >< th > 操作</ th ></ tr ></ thead >< tbody >< tr >< td >< strong > 宿舍东侧 03</ strong >< br >< span class = "badge danger" > P90</ span ></ td >< td > Brain subscribed</ td >< td > worker not_found</ td >< td > 第 2 次 · 20s</ td >< td >< time title = "2026-08-04 10:49:10 +08:00" > 22 秒后</ time ></ td >< td >< button class = "btn btn-sm" data-write-action > 立即重试</ button ></ td ></ tr >< tr >< td >< strong > 北围墙 02</ strong >< br >< span class = "badge warn" > P60</ span ></ td >< td > Media running</ td >< td > path idle</ td >< td > 第 1 次 · 10s</ td >< td >< time title = "2026-08-04 10:49:24 +08:00" > 36 秒后</ time ></ td >< td >< button class = "btn btn-sm" data-write-action > 立即重试</ button ></ td ></ tr ></ tbody ></ table ></ div >< div class = "card-body" >< div class = "banner danger" >< div >< strong > 发现 5 个孤儿 path</ strong >< div > 占当前 32 个实际 path 的 15.6%,超过 10% 安全闸,自动删除已阻止。</ div ></ div >< button class = "btn btn-danger" data-write-action id = "reviewOrphans" > 人工复核</ button ></ div ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "op-panel-shards" aria-labelledby = "op-tab-shards" hidden >< div class = "card-body grid system-grid" >< article class = "cap" >< strong > media-01</ strong >< p >< span class = "badge success" > 健康</ span > · 当前承载 16 路</ p >< p > 输入 54 Mbps · readers 15 · 重连 1</ p >< p > `max_streams=32` 是初始配置建议,不是产品硬上限,最终需压测确认。</ p ></ article >< article class = "cap" >< strong > inference-01</ strong >< p >< span class = "badge success" > 健康</ span > · 15 / 16 源</ p >< p > p95 73ms · batch 4 · reconnect 0</ p >< p > 扩容以 GPU、FPS、分辨率基准为准。</ p ></ article >< article class = "cap" >< strong > 故障域说明</ strong >< p > 当前启用 1 个媒体分片,逻辑最多 4 个。</ p >< p > 迁移工作流属于 M4;本原型只展示归属和影响。</ p ></ article ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "op-panel-edge" aria-labelledby = "op-tab-edge" hidden >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 节点 / 版本</ th >< th > 在线时长 / 承载</ th >< th > 控制面隧道</ th >< th > 视频数据面</ th >< th > 补传</ th >< th > 最近心跳</ th ></ tr ></ thead >< tbody >< tr >< td > edge-north< br >< span class = "muted" > agent 0.3.2</ span ></ td >< td > 12d 06h · 9 路</ td >< td >< span class = "badge success" > connected</ span ></ td >< td >< span class = "badge success" > normal</ span ></ td >< td > 0</ td >< td >< time title = "2026-08-04 10:48:39 +08:00" > 9 秒前</ time ></ td ></ tr >< tr >< td > edge-east< br >< span class = "muted" > agent 0.3.2</ span ></ td >< td > 3d 11h · 7 路</ td >< td >< span class = "badge danger" > disconnected</ span ></ td >< td >< span class = "badge success" > normal</ span ></ td >< td > 86 MB · 42%</ td >< td >< time title = "2026-08-04 10:46:42 +08:00" > 2 分钟前</ time ></ td ></ tr >< tr >< td > edge-spare< br >< span class = "muted" > agent 0.3.1</ span ></ td >< td > 维护中 · 0 路</ td >< td >< span class = "badge warn" > maintenance</ span ></ td >< td > 无绑定</ td >< td > 0</ td >< td >< time title = "2026-08-04 09:20:00 +08:00" > 1 小时前</ time ></ td ></ tr ></ tbody ></ table ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "op-panel-alerts" aria-labelledby = "op-tab-alerts" hidden >< div class = "card-body list" >< div class = "list-item" >< div >< strong > P1 · Area 策略投影超过 5 分钟未更新</ strong >< span class = "muted" > 仅阻止成像设备新增/启用;已有流继续</ span ></ div >< button class = "btn btn-sm" > 查看依赖</ button ></ div >< div class = "list-item" >< div >< strong > P2 · edge-east 隧道断开</ strong >< span class = "muted" > 数据面正常,补传队列 86 MB</ span ></ div >< button class = "btn btn-sm" > 查看节点</ button ></ div >< div class = "list-item" >< div >< strong > P2 · 北门 02 认证失败</ strong >< span class = "muted" > 连续 3 次认证拒绝,自动重试已暂停</ span ></ div >< button class = "btn btn-sm" data-write-action > 更新凭据</ button ></ div ></ div ></ section >
< section class = "tabpanel" role = "tabpanel" id = "op-panel-status" aria-labelledby = "op-tab-status" hidden >< div class = "card-body grid system-grid" >< article class = "cap" >< strong > Sense API</ strong >< p >< span class = "badge success" > 健康</ span ></ p >< p > 运行 12d 06h · queue 2</ p ></ article >< article class = "cap" >< strong > MediaMTX</ strong >< p >< span class = "badge success" > 健康</ span ></ p >< p > 1 / 1 实例 · 32 paths</ p ></ article >< article class = "cap" >< strong > PostgreSQL</ strong >< p >< span class = "badge success" > 健康</ span ></ p >< p > 连接 8 / 50 · p95 7ms</ p ></ article ></ div ></ section >
</ article >
2026-08-04 00:23:55 +08:00
</ section >
</ div >
</ main >
</ div >
2026-08-04 11:08:41 +08:00
< nav class = "mobile-nav" aria-label = "移动端主导航" >< button data-view = "overview" > 总览</ button >< button data-view = "monitor" > 监控</ button >< button data-view = "devices" class = "active" aria-current = "page" > 设备</ button >< button data-view = "onboarding" > 接入</ button >< button data-view = "operations" > 运维</ button ></ nav >
2026-08-04 00:23:55 +08:00
2026-08-04 11:08:41 +08:00
< div class = "state-overlay" id = "stateOverlay" hidden >< div id = "stateOverlayBody" ></ div ></ div >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" hidden ></ div >
2026-08-04 10:05:56 +08:00
2026-08-04 11:08:41 +08:00
< dialog id = "addDialog" aria-labelledby = "addTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "addTitle" > 添加设备</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< div class = "form-grid" >< label class = "field" >< span > 设备模态</ span >< select class = "select" id = "addModality" >< option value = "video" > 视频摄像头</ option >< option value = "radar" > 毫米波雷达(M6) </ option ></ select ></ label >< label class = "field" >< span > Area</ span >< select class = "select" id = "addArea" >< option value = "gate" > 北门 · 允许成像</ option >< option value = "dorm" > 宿舍卧室 · 仅非成像</ option ></ select ></ label >< label class = "field full" >< span > 名称</ span >< input class = "input" placeholder = "例如:北门 03" required ></ label >< label class = "field" >< span > 稳定序列号</ span >< input class = "input" placeholder = "不使用 IP 作为身份" required ></ label >< label class = "field" id = "profileField" >< span > 连接方式</ span >< select class = "select" >< option > ONVIF 探测</ option >< option > 手动 RTSP</ option ></ select ></ label ></ div >< div class = "banner danger" id = "privacyBlock" hidden >< div >< strong > 该 Area 不允许成像设备</ strong >< div > 请改选允许成像的 Area;不能通过换显示名称绕过。</ div ></ div ></ div >< p class = "muted" > 保存后进入“待激活”,探测成功且配额/Area 策略通过后才接入。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button type = "button" class = "btn btn-primary" id = "savePending" > 保存为待激活</ button ></ div ></ form ></ dialog >
< dialog id = "credentialDialog" aria-labelledby = "credentialTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "credentialTitle" > 更新设备凭据</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< div class = "banner" >< div >< strong > 旧凭据永不回显</ strong >< div > 提交后立即写入密钥存储,并触发一次认证探测。</ div ></ div ></ div >< div class = "form-grid" >< label class = "field" >< span > 用户名</ span >< input class = "input" autocomplete = "off" ></ label >< label class = "field" >< span > 新密码</ span >< input class = "input" type = "password" autocomplete = "new-password" ></ label ></ div ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-primary" value = "default" id = "saveCredential" > 更新并探测</ button ></ div ></ form ></ dialog >
< dialog id = "profileDialog" aria-labelledby = "profileTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "profileTitle" > 切换 ONVIF Profile</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< label class = "radio-card" >< input type = "radio" name = "profile" checked > Profile_1 · 1920× 1080 · H.264 · 当前生效</ label >< label class = "radio-card" >< input type = "radio" name = "profile" > Profile_2 · 640× 360 · H.264</ label >< p class = "muted" > 切换会重新建立媒体 path,短时影响观看、推理与录制;检测区域进入待校准,不自动修改坐标。</ p ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 取消</ button >< button class = "btn btn-primary" value = "default" id = "saveProfile" > 确认切换</ button ></ div ></ form ></ dialog >
< dialog id = "capDiffDialog" aria-labelledby = "capDiffTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "capDiffTitle" > 能力探测差异</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< p > 新探测 probe-8429 · < time title = "2026-08-04 10:49:22 +08:00" > 刚刚</ time ></ p >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 字段</ th >< th > 上次探测</ th >< th > 本次探测</ th >< th > 生效值</ th ></ tr ></ thead >< tbody >< tr >< td > 主 Profile</ td >< td > Profile_1</ td >< td > Profile_1</ td >< td > Profile_1</ td ></ tr >< tr >< td > 事件订阅</ td >< td > PullPoint</ td >< td > PullPoint + motion</ td >< td >< span class = "badge warn" > 待确认</ span ></ td ></ tr >< tr >< td > 设备时间</ td >< td > +0.2s</ td >< td > +1.8s</ td >< td > 等待校时</ td ></ tr ></ tbody ></ table ></ div ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 保留当前</ button >< button class = "btn btn-primary" value = "default" > 应用可兼容差异</ button ></ div ></ form ></ dialog >
< dialog id = "operationDialog" aria-labelledby = "operationTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "operationTitle" > 设备接入控制</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< label class = "radio-card" >< input type = "radio" name = "operation" value = "pause" checked > < strong > 暂停推理订阅</ strong >< br >< span class = "muted" > 解除推理侧对该路的订阅;有其他观看者时上游继续拉流,无其他 reader 时上游按需停止。</ span ></ label >< label class = "radio-card" >< input type = "radio" name = "operation" value = "disable" > < strong > 停用设备接入</ strong >< br >< span class = "muted" > 期望态改为 disabled;影响观看、推理、录制和后续证据回捞,实际态由对账器收敛。</ span ></ label >< label class = "radio-card" >< input type = "radio" name = "operation" value = "kick" > < strong > 仅踢当前会话</ strong >< br >< span class = "muted" > 用于异常连接恢复,不改变设备期望态,客户端可以重新连接。</ span ></ label >< div class = "banner danger" >< div >< strong > 这是高风险操作</ strong >< div > 每次请求都会留下审计结果;幂等只避免重复副作用。</ div ></ div ></ 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 >
< dialog id = "taskDialog" aria-labelledby = "taskTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "taskTitle" > 宿舍楼补点 · 逐项结果</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< div class = "table-wrap" >< table class = "table" >< thead >< tr >< th > 行</ th >< th > 设备</ th >< th > 结果</ th >< th > 原因 / 下一步</ th >< th > 操作</ th ></ tr ></ thead >< tbody >< tr >< td > 2</ td >< td > 食堂后门 04</ td >< td >< span class = "badge warn" > 待激活</ span ></ td >< td > 探测完成,等待确认</ td >< td >< button type = "button" class = "btn btn-sm" data-write-action data-retry-row > 复核并激活</ button ></ td ></ tr >< tr >< td > 3</ td >< td > 宿舍东侧 03</ td >< td >< span class = "badge success" > 成功</ span ></ td >< td > 已接入 media-01</ td >< td > —</ td ></ tr >< tr >< td > 4</ td >< td > 北门 02</ td >< td >< span class = "badge danger" > 失败</ span ></ td >< td > AUTH_REJECTED · 更新凭据</ td >< td >< button type = "button" class = "btn btn-sm" data-write-action data-retry-row > 重试此行</ button ></ td ></ tr ></ tbody ></ table ></ div >< p class = "muted" > 部分成功不会整体回滚:成功项已生效,失败项保持可重试。</ p ></ div >< div class = "dialog-foot" >< button type = "button" class = "btn" id = "exportErrors" > 导出错误清单</ button >< button class = "btn" value = "cancel" > 关闭</ button >< button type = "button" class = "btn btn-primary" data-write-action id = "retryFailed" > 仅重试失败项</ button ></ div ></ form ></ dialog >
< dialog id = "orphanDialog" aria-labelledby = "orphanTitle" >< form method = "dialog" >< div class = "dialog-head" >< h2 id = "orphanTitle" > 孤儿 path 人工恢复</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭" > × </ button ></ div >< div class = "dialog-body" >< p > 安全闸已阻止本批删除,原型不提供绕过入口。先导出 5 个对象及 publisher、reader、录像索引与待回捞证据,再由独立维护任务分批处置。</ p >< label class = "field" >< span > 处置原因</ span >< textarea class = "textarea" placeholder = "必填,写入操作审计" ></ textarea ></ label ></ div >< div class = "dialog-foot" >< button class = "btn" value = "cancel" > 关闭</ button >< button class = "btn btn-primary" value = "default" > 创建人工处置任务</ button ></ div ></ form ></ dialog >
2026-08-04 00:23:55 +08:00
< script >
2026-08-04 11:08:41 +08:00
const $ = s => document . querySelector ( s ), $$ = s =>[... document . querySelectorAll ( s )];
const devices = [
{ id : 'dev-001' , name : '北围墙 01' , serial : 'SN-HK-001' , modality : 'video' , status : 'online' , desired : 'enabled' , actual : 'running' , area : '北围墙' , changed : '2026-08-04 10:43:03' , ago : '5 分钟前' },
{ id : 'dev-002' , name : '北围墙 02' , serial : 'SN-HK-002' , modality : 'video' , status : 'online' , desired : 'enabled' , actual : 'media idle' , area : '北围墙' , changed : '2026-08-04 10:47:32' , ago : '1 分钟前' },
{ id : 'dev-003' , name : '宿舍东侧 03' , serial : 'SN-DA-003' , modality : 'video' , status : 'online' , desired : 'enabled' , actual : 'inference missing' , area : '宿舍外廊' , changed : '2026-08-04 10:45:10' , ago : '3 分钟前' },
{ id : 'dev-004' , name : '北门 02' , serial : 'SN-HK-009' , modality : 'video' , status : 'auth_failed' , desired : 'enabled' , actual : 'auth rejected' , area : '北门' , changed : '2026-08-04 10:31:08' , ago : '17 分钟前' },
{ id : 'dev-005' , name : '食堂后门 04' , serial : 'SN-UN-021' , modality : 'video' , status : 'pending' , desired : 'pending' , actual : 'not provisioned' , area : '食堂后门' , changed : '2026-08-04 10:40:00' , ago : '8 分钟前' },
{ id : 'dev-006' , name : '体育馆 01' , serial : 'SN-HK-017' , modality : 'video' , status : 'offline' , desired : 'enabled' , actual : 'unreachable' , area : '体育馆' , changed : '2026-08-04 09:48:00' , ago : '1 小时前' },
{ id : 'dev-007' , name : '宿舍走廊雷达 01' , serial : 'RD-MW-001' , modality : 'radar' , status : 'adapter_not_ready' , desired : 'pending' , actual : 'adapter not ready' , area : '宿舍走廊' , changed : '2026-08-04 09:20:00' , ago : '1 小时前' },
{ id : 'dev-008' , name : '图书馆 01' , serial : 'SN-HK-022' , modality : 'video' , status : 'online' , desired : 'enabled' , actual : 'running' , area : '图书馆' , changed : '2026-08-04 10:47:40' , ago : '1 分钟前' }
2026-08-04 00:23:55 +08:00
];
2026-08-04 11:08:41 +08:00
let page = 1 , currentDevice = devices [ 0 ], role = 'admin' , dependency = 'normal' , activeSite = 'ivy' , points = [[ 18 , 72 ],[ 38 , 34 ],[ 74 , 42 ],[ 82 , 78 ]];
const statusMeta = { online : [ '在线' , 'success' ], pending : [ '待激活' , 'warn' ], auth_failed : [ '认证失败' , 'danger' ], offline : [ '离线' , 'danger' ], adapter_not_ready : [ '适配器未就绪' , 'warn' ]};
function toast ( msg ){ const t = $ ( '#toast' ); t . textContent = msg ; t . hidden = false ; clearTimeout ( window . toastTimer ); window . toastTimer = setTimeout (()=> t . hidden = true , 3200 )}
function switchView ( name ){ const navName = name === 'device-detail' ? 'devices' : name ; $$ ( '.view' ). forEach ( v => v . classList . toggle ( 'active' , v . dataset . page === name )); $$ ( '[data-view]' ). forEach ( b =>{ const on = b . dataset . view === navName ; b . classList . toggle ( 'active' , on ); b . toggleAttribute ( 'aria-current' , on )}); $ ( '#main-content' ). focus ({ preventScroll : true })}
$$ ( '[data-view]' ). forEach ( b => b . addEventListener ( 'click' ,()=> switchView ( b . dataset . view ))); $$ ( '[data-jump]' ). forEach ( b => b . addEventListener ( 'click' ,()=>{ switchView ( b . dataset . jump ); if ( b . dataset . opTab ) selectOpsTab ( b . dataset . opTab )}));
function renderDevices (){ const q = $ ( '#deviceSearch' ). value . trim (). toLowerCase (), m = $ ( '#modalityFilter' ). value , s = $ ( '#statusFilter' ). value , siteRows = activeSite === 'ivy' ? devices : devices . slice ( 0 , 3 ); const rows = siteRows . filter ( d =>( ! q || ( d . name + d . serial ). toLowerCase (). includes ( q )) && ( m === 'all' || d . modality === m ) && ( s === 'all' || d . status === s )); const pages = Math . max ( 1 , Math . ceil ( rows . length / 5 )); page = Math . min ( page , pages ); const slice = rows . slice (( page - 1 ) * 5 , page * 5 ); $ ( '#deviceRows' ). innerHTML = slice . length ? slice . map ( d =>{ const meta = statusMeta [ d . status ]; return `<tr><td><button class="link-btn" data-device=" ${ d . id } "><strong> ${ d . name } </strong><br><span class="muted"> ${ d . serial } </span></button></td><td> ${ d . modality === 'video' ? '视频 · 成像/媒体' : '雷达 · 遥测(M6) ' } </td><td><span class="badge ${ meta [ 1 ] } "> ${ meta [ 0 ] } </span></td><td> ${ d . desired } <br><span class="muted"> ${ d . actual } </span></td><td> ${ d . area } </td><td><time title=" ${ d . changed } +08:00"> ${ d . ago } </time></td><td><button class="btn btn-sm" data-device=" ${ d . id } ">查看</button></td></tr>` }). join ( '' ) : `<tr><td colspan="7"><div class="empty-state"><h2>没有匹配设备</h2><p>清除筛选或检查当前站点。</p></div></td></tr>` ; $ ( '#pageSummary' ). textContent = `第 ${ page } / ${ pages } 页 · 共 ${ rows . length } 项` ; $ ( '#prevPage' ). disabled = page <= 1 ; $ ( '#nextPage' ). disabled = page >= pages ; $$ ( '[data-device]' ). forEach ( b => b . onclick = ()=> openDevice ( b . dataset . device ))}
[ 'deviceSearch' , 'modalityFilter' , 'statusFilter' ]. forEach ( id => $ ( '#' + id ). addEventListener ( id === 'deviceSearch' ? 'input' : 'change' ,()=>{ page = 1 ; renderDevices ()})); $ ( '#clearFilters' ). onclick = ()=>{ $ ( '#deviceSearch' ). value = '' ; $ ( '#modalityFilter' ). value = 'all' ; $ ( '#statusFilter' ). value = 'all' ; page = 1 ; renderDevices ()}; $ ( '#prevPage' ). onclick = ()=>{ page -- ; renderDevices ()}; $ ( '#nextPage' ). onclick = ()=>{ page ++ ; renderDevices ()};
function openDevice ( id ){ currentDevice = devices . find ( d => d . id === id ) || devices [ 0 ]; const meta = statusMeta [ currentDevice . status ]; $ ( '#detailName' ). textContent = currentDevice . name ; $ ( '#detailSerial' ). textContent = currentDevice . serial ; $ ( '#detailModality' ). textContent = currentDevice . modality === 'video' ? '视频设备' : '雷达设备 · M6' ; $ ( '#detailStatus' ). textContent = meta [ 0 ]; $ ( '#detailStatus' ). className = 'badge ' + meta [ 1 ]; $$ ( '[data-video-only]' ). forEach ( e => e . hidden = currentDevice . modality !== 'video' ); $ ( '#profileBtn' ). hidden = currentDevice . modality !== 'video' || role === 'readonly' ; $ ( '#credentialBtn' ). hidden = currentDevice . modality !== 'video' || role === 'readonly' ; $ ( '#summaryKv' ). innerHTML = `<div><dt>期望态</dt><dd> ${ currentDevice . desired } </dd></div><div><dt>实际态</dt><dd> ${ currentDevice . actual } </dd></div><div><dt>Area</dt><dd> ${ currentDevice . area } · policy v42</dd></div><div><dt>分片</dt><dd> ${ currentDevice . modality === 'video' ? 'media-01 / inference-01' : 'signal plane( M6) ' } </dd></div>` ; $ ( '#lifecycleList' ). innerHTML = currentDevice . status === 'pending' ? `<div class="list-item"><div><strong>等待激活</strong><span class="muted">先完成重新探测、校时、视频配额和 Area policy v42 复核,再写 enabled 期望态。</span></div><div class="actions"><button class="btn" data-write-action id="recheckPending">重新探测 / 复核</button><button class="btn btn-primary" data-write-action id="activateNow">确认激活</button></div></div>` : currentDevice . status === 'auth_failed' ? `<div class="list-item"><div><strong>认证失败,自动重试已暂停</strong><span class="muted">AUTH_REJECTED · 更新凭据后重新探测。</span></div><button class="btn btn-primary" data-write-action id="fixCredential">更新凭据</button></div>` : `<div class="list-item"><div><strong>接入已激活</strong><span class="muted">期望态 ${ currentDevice . desired } · 实际态 ${ currentDevice . actual } </span></div><span class="badge ${ meta [ 1 ] } "> ${ meta [ 0 ] } </span></div>` ; const caps = currentDevice . modality === 'video' ? [[ '设备身份' , 'Hikvision DS-2CD · FW 5.7.18' , 'ONVIF probe-8421' ],[ '采购白名单' , '已验证型号 · M0 文档记录' , '只读结果,无在线维护页' ],[ '认证' , 'WS-Security UsernameToken' , '探测值 = 生效值' ],[ 'Profiles' , '2 个 · H.264' , 'Profile_1 生效' ],[ '校时' , 'SetSystemDateAndTime 支持' , '漂移 +0.2s' ],[ '事件订阅' , 'PullPoint 支持' , 'motion 待确认' ],[ '媒体' , 'RTSP main/sub' , '主码流推理 · 子码流预览' ]] : [[ '适配器' , 'mmWave 协议适配器' , 'adapter_not_ready' ],[ '交付阶段' , 'M6' , '当前没有真实连接' ],[ '能力声明' , 'telemetry / occupancy' , '仅设计值,尚未探测' ]]; $ ( '#capabilityGrid' ). innerHTML = caps . map ( x => `<article class="cap"><strong> ${ x [ 0 ] } </strong><p> ${ x [ 1 ] } </p><p> ${ x [ 2 ] } </p></article>` ). join ( '' ); $ ( '#healthConnection' ). textContent = currentDevice . status === 'adapter_not_ready' ? '协议适配器尚未交付;无连接、无模拟遥测' : currentDevice . actual ; $ ( '#auditLink' ). onclick = ()=> location . href = `../bell/index.html?view=audit&tenant=tenant-ivy&site=site-ivy&device= ${ currentDevice . id } &return_to=sense-device` ; $ ( '#bellRuleLink' ). href = `../bell/index.html?view=rules&camera= ${ currentDevice . id } &zone=zone-a&return_to=sense-device` ; switchDetailTab ( 'summary' ); switchView ( 'device-detail' ); const a = $ ( '#activateNow' ), f = $ ( '#fixCredential' ), r = $ ( '#recheckPending' ); if ( a ) a . onclick = ()=> toast ( '已提交激活期望态;等待对账收敛' ); if ( f ) f . onclick = ()=> $ ( '#credentialDialog' ). showModal (); if ( r ) r . onclick = ()=> toast ( '探测与校时完成;配额 16/128、Area policy v42 均通过' ); applyRole ()}
$ ( '#backToDevices' ). onclick = ()=> switchView ( 'devices' );
function bindTabs ( selector , callback ){ const tabs = $$ ( selector ); tabs . forEach (( tab , i )=>{ tab . addEventListener ( 'click' ,()=> callback ( tab )); tab . addEventListener ( 'keydown' , e =>{ if ( ! [ 'ArrowLeft' , 'ArrowRight' , 'Home' , 'End' ]. includes ( e . key )) return ; e . preventDefault (); let n = e . key === 'Home' ? 0 : e . key === 'End' ? tabs . length - 1 : ( i + ( e . key === 'ArrowRight' ? 1 :- 1 ) + tabs . length ) % tabs . length ; tabs [ n ]. focus (); callback ( tabs [ n ])})})}
function switchDetailTab ( name ){ const tab = $ ( '#tab-' + name ); if ( ! tab || tab . hidden ) return ; $$ ( '[id^="tab-"]' ). filter ( t => t . closest ( '.card' ) && t . getAttribute ( 'role' ) === 'tab' &&! t . dataset . opsTab ). forEach ( t =>{ const on = t === tab ; t . setAttribute ( 'aria-selected' , on ); t . tabIndex = on ? 0 :- 1 ; const p = $ ( '#' + t . getAttribute ( 'aria-controls' )); if ( p ) p . hidden =! on })}
bindTabs ( '.detail-summary + .card .tabs [role="tab"]' , t => switchDetailTab ( t . id . replace ( 'tab-' , '' )));
function selectOpsTab ( name ){ $$ ( '[data-ops-tab]' ). forEach ( t =>{ const on = t . dataset . opsTab === name ; t . setAttribute ( 'aria-selected' , on ); t . tabIndex = on ? 0 :- 1 ; $ ( '#' + t . getAttribute ( 'aria-controls' )). hidden =! on })}
bindTabs ( '[data-ops-tab]' , t => selectOpsTab ( t . dataset . opsTab ));
$$ ( '[data-open-add]' ). forEach ( b => b . onclick = ()=> $ ( '#addDialog' ). showModal ()); function updatePrivacy (){ const block = $ ( '#addModality' ). value === 'video' && $ ( '#addArea' ). value === 'dorm' ; $ ( '#privacyBlock' ). hidden =! block ; $ ( '#savePending' ). disabled = block || dependency !== 'normal' ; $ ( '#profileField' ). hidden = $ ( '#addModality' ). value !== 'video' } $ ( '#addModality' ). onchange = updatePrivacy ; $ ( '#addArea' ). onchange = updatePrivacy ; $ ( '#savePending' ). onclick = ()=>{ $ ( '#addDialog' ). close (); toast ( '设备已保存为待激活,可在接入任务查看逐项结果' )};
$ ( '#credentialBtn' ). onclick = ()=> $ ( '#credentialDialog' ). showModal (); $ ( '#profileBtn' ). onclick = ()=> $ ( '#profileDialog' ). showModal (); $ ( '#reprobeBtn' ). onclick = ()=> $ ( '#capDiffDialog' ). showModal (); $ ( '#lifecycleBtn' ). onclick = ()=> $ ( '#operationDialog' ). showModal (); $ ( '#confirmOperation' ). onclick = ()=>{ const op = $ ( 'input[name="operation"]:checked' ). value ; $ ( '#operationDialog' ). close (); toast ( op === 'pause' ? '推理订阅:已解除;其他 reader:2;上游:继续拉流' : op === 'disable' ? '期望态已改为 disabled;实际态正在收敛' : '当前会话已踢出;期望态未改变' )}; $ ( '#saveCredential' ). onclick = ()=> toast ( '新凭据已写入,正在重新探测;旧值未回显' ); $ ( '#saveProfile' ). onclick = ()=> toast ( 'Profile 切换已进入对账队列;检测区域标记待校准' ); $$ ( '[data-task-result]' ). forEach ( b => b . onclick = ()=> $ ( '#taskDialog' ). showModal ()); $ ( '#retryFailed' ). onclick = ()=>{ toast ( '已创建仅含 1 个失败项的重试任务' ); $ ( '#taskDialog' ). close ()}; $$ ( '[data-retry-row]' ). forEach ( b => b . onclick = ()=> toast ( '已创建单行重试任务,不影响已成功项' )); $ ( '#exportErrors' ). onclick = ()=> toast ( '错误清单已按当前任务生成(原型不写入文件)' ); $ ( '#reviewOrphans' ). onclick = ()=> $ ( '#orphanDialog' ). showModal ();
function renderVertices (){ const box = $ ( '#vertexList' ); box . innerHTML = points . map (( p , i )=> `<div class="vertex"><input class="input" aria-label="点 ${ i + 1 } X" type="number" min="0" max="100" value=" ${ p [ 0 ] } " data-axis="0" data-index=" ${ i } "><input class="input" aria-label="点 ${ i + 1 } Y" type="number" min="0" max="100" value=" ${ p [ 1 ] } " data-axis="1" data-index=" ${ i } "><button class="btn icon-btn" data-delete-point=" ${ i } " data-write-action aria-label="删除点 ${ i + 1 } ">× </button></div>` ). join ( '' ); $ ( '#zonePolygon' ). setAttribute ( 'points' , points . map ( p => p . join ( ',' )). join ( ' ' )); $$ ( '[data-axis]' ). forEach ( i => i . onchange = ()=>{ points [ + i . dataset . index ][ + i . dataset . axis ] = Math . max ( 0 , Math . min ( 100 , + i . value )); renderVertices (); saveDraft ()}); $$ ( '[data-delete-point]' ). forEach ( b => b . onclick = ()=>{ if ( points . length <= 3 ) return toast ( '多边形至少需要 3 个点' ); points . splice ( + b . dataset . deletePoint , 1 ); renderVertices (); saveDraft ()}); applyRole ()}
function saveDraft (){ sessionStorage . setItem ( 'sense-zone-draft' , JSON . stringify ( points )); $ ( '#draftState' ). textContent = '草稿已自动保存到当前会话 · 刚刚' } $ ( '#addVertex' ). onclick = ()=>{ points . push ([ 50 , 50 ]); renderVertices (); saveDraft ()}; $ ( '#reverseZone' ). onclick = ()=>{ points . reverse (); renderVertices (); saveDraft ()}; $ ( '#finishZone' ). onclick = ()=> toast ( points . length >= 3 ? '绘制已完成,可保存草稿' : '至少需要 3 个点' ); $ ( '#saveZone' ). onclick = ()=> toast ( '检测区域草稿已保存为 v8;不会自动发布 Bell 规则' );
function applyRole (){ role = $ ( '#roleSwitcher' ). value ; $$ ( '[data-write-action]' ). forEach ( e => e . hidden = role === 'readonly' ); if ( role === 'readonly' ) toast ( '已切换为只读:所有写操作已隐藏' )}
$ ( '#roleSwitcher' ). onchange = applyRole ;
function renderState ( state ){ dependency = state ; const overlay = $ ( '#stateOverlay' ), body = $ ( '#stateOverlayBody' ), banner = $ ( '#degradeBanner' ); overlay . hidden = true ; banner . hidden = true ; $$ ( '[data-write-action]' ). forEach ( e =>{ if ( role !== 'readonly' ) e . hidden = false ; e . disabled = false }); if ( state === 'loading' ){ body . innerHTML = '<div class="spinner"></div><h2>正在读取站点状态</h2><p class="muted">不会用旧数据伪装最新状态。</p>' ; overlay . hidden = false } else if ( state === 'empty' ){ body . innerHTML = '<h2>这个站点还没有设备</h2><p class="muted">可从接入任务发现或批量导入设备。</p><button class="btn btn-primary" onclick="document.querySelector(\'[data-view=onboarding]\').click()">开始接入</button>' ; overlay . hidden = false } else if ( state === 'error' ){ body . innerHTML = '<h2>无法读取最新状态</h2><p class="muted">当前数据可能已过期,未知不会显示为在线。</p><button class="btn" onclick="document.querySelector(\'#stateSelect\').value=\'normal\';renderState(\'normal\')">重试</button>' ; overlay . hidden = false } else if ( state === 'denied' ){ body . innerHTML = '<h2>无权访问此站点</h2><p class="muted">请求已被安全拒绝;页面不会确认目标设备是否存在。</p><button class="btn" onclick="history.back()">返回上一页</button>' ; overlay . hidden = false } else if ( state === 'quota_unavailable' || state === 'policy_unavailable' ){ banner . hidden = false ; $ ( '#degradeTitle' ). textContent = state === 'quota_unavailable' ? '配额服务暂时不可用' : 'Area 策略投影暂时不可用' ; $ ( '#degradeText' ). textContent = state === 'quota_unavailable' ? '已有设备、视频和列表继续可用;新增与启用已禁用。' : '已有链路保持原状态;涉及设备新增或启用的写操作已禁用并产生运维告警。' ; $$ ( '[data-open-add],#savePending,#activateNow' ). forEach ( e =>{ if ( e ) e . disabled = true })} applyRole (); updatePrivacy ()}
$ ( '#stateSelect' ). onchange = e => renderState ( e . target . value ); $ ( '#retryDependency' ). onclick = ()=>{ $ ( '#stateSelect' ). value = 'normal' ; renderState ( 'normal' ); toast ( '依赖已恢复,写操作重新可用' )};
$ ( '#siteSwitcher' ). onchange = e =>{ activeSite = e . target . value ; $ ( '#siteName' ). textContent = e . target . selectedOptions [ 0 ]. textContent ; $$ ( '[data-view="devices"] .count' ). forEach ( c => c . textContent = activeSite === 'ivy' ? '17' : '3' ); page = 1 ; renderDevices (); toast ( `站点上下文已切换,当前示例设备 ${ activeSite === 'ivy' ? '17' : '3' } 台` )}; $ ( '#refreshOps' ). onclick = ()=> toast ( '实际态已刷新 · 2026-08-04 10:49:22 +08:00' );
const route = new URLSearchParams ( location . search ); if ( route . get ( 'denied' ) === '1' ){ $ ( '#stateSelect' ). value = 'denied' ; renderState ( 'denied' )} if ( route . get ( 'view' )) switchView ( route . get ( 'view' )); if ( route . get ( 'device' )) openDevice ( route . get ( 'device' ));
const draft = sessionStorage . getItem ( 'sense-zone-draft' ); if ( draft ){ try { points = JSON . parse ( draft )} catch {}} renderVertices (); renderDevices (); applyRole (); setInterval (()=>{ const d = new Date (); $ ( '#serverClock' ). textContent = new Intl . DateTimeFormat ( 'zh-CN' ,{ timeZone : 'Asia/Shanghai' , hour12 : false , year : 'numeric' , month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' , second : '2-digit' }). format ( d )}, 1000 );
2026-08-04 00:23:55 +08:00
</ script >
</ body >
</ html >