2026-08-04 00:23:55 +08:00
<!doctype html>
< html lang = "zh-CN" data-theme = "dark" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< meta name = "color-scheme" content = "dark light" >
< link rel = "icon" href = "data:," >
< title > YoVision Sense · 设备与视频接入原型</ title >
< style >
:root {
--bg: #08111f;
--surface: #0d1929;
--surface-2: #111f31;
--surface-3: #17263a;
--text: #f2f7fb;
--muted: #9fb0c5;
--subtle: #71849c;
--line: #27384d;
--primary: #38bdf8;
--primary-strong: #0ea5e9;
--primary-soft: rgba(56, 189, 248, .14);
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--violet: #a78bfa;
--shadow: 0 16px 40px rgba(0, 0, 0, .22);
--radius: 14px;
--sidebar: 224px;
--banner: 28px;
}
html[data-theme="light"] {
--bg: #eef4f8;
--surface: #ffffff;
--surface-2: #f7fafc;
--surface-3: #eaf1f6;
--text: #112033;
--muted: #50647b;
--subtle: #718096;
--line: #cfdae5;
--primary-soft: rgba(2, 132, 199, .11);
--shadow: 0 14px 34px rgba(38, 64, 92, .12);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body {
margin: 0;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font: 14px/1.5 Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
button, input, select, textarea { font: inherit; }
button, select, input[type="checkbox"], input[type="radio"] { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
outline: 3px solid rgba(56, 189, 248, .45);
outline-offset: 2px;
}
.prototype-banner {
position: fixed; inset: 0 0 auto 0; z-index: 1000;
height: var(--banner); display: flex; align-items: center; justify-content: center;
background: #f6c344; color: #211900; font-size: 12px; font-weight: 800; letter-spacing: .07em;
}
.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
.app { min-height: 100dvh; padding-top: var(--banner); display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
.sidebar {
position: sticky; top: var(--banner); height: calc(100dvh - var(--banner));
border-right: 1px solid var(--line); background: var(--surface); padding: 18px 12px;
display: flex; flex-direction: column; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 2px 10px 20px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, #38bdf8, #6366f1); color: white; font-weight: 900; box-shadow: 0 8px 20px rgba(14, 165, 233, .22); }
.brand strong { display: block; font-size: 16px; letter-spacing: .01em; }
.brand small { color: var(--muted); }
.nav-label { margin: 8px 10px 6px; color: var(--subtle); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; }
.nav { display: grid; gap: 4px; }
.nav button {
min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--muted);
display: flex; align-items: center; gap: 10px; text-align: left; transition: background .2s ease, color .2s ease;
}
.nav button:hover { color: var(--text); background: var(--surface-2); }
.nav button[aria-current="page"] { color: var(--text); background: var(--primary-soft); box-shadow: inset 3px 0 var(--primary); }
.nav .count { margin-left: auto; min-width: 22px; padding: 1px 6px; border-radius: 999px; background: var(--surface-3); color: var(--text); text-align: center; font-size: 12px; }
.sidebar-foot { margin-top: auto; padding: 12px 10px 2px; border-top: 1px solid var(--line); color: var(--muted); }
.online-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.main { min-width: 0; }
.topbar {
position: sticky; top: var(--banner); z-index: 20; min-height: 64px; padding: 10px 22px;
display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(12px);
}
.site-select { display: flex; align-items: center; gap: 8px; min-width: 250px; }
.site-select strong { font-size: 15px; }
.site-select span { color: var(--muted); font-size: 12px; }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.select, .field, .textarea {
width: 100%; min-height: 40px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px;
}
.textarea { min-height: 74px; resize: vertical; }
.top-actions .select { width: auto; min-width: 126px; }
.btn {
min-height: 40px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text);
2026-08-04 09:04:50 +08:00
display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; text-decoration: none; transition: border-color .2s ease, background .2s ease, opacity .2s ease;
2026-08-04 00:23:55 +08:00
}
.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: 42px; padding: 0; }
.content { padding: 22px; }
.view { display: none; animation: reveal .22s ease-out; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: 24px; line-height: 1.25; }
.page-head p { margin: 0; color: var(--muted); }
.page-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.grid { display: grid; gap: 14px; }
.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.stat { padding: 16px; min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
.stat-top { display: flex; align-items: center; justify-content: space-between; color: var(--muted); }
.stat strong { font-size: 27px; font-variant-numeric: tabular-nums; }
.trend { font-size: 12px; color: var(--success); }
.split { grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); margin-top: 14px; }
.card-head { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--line); }
.card-head h2, .card-head h3 { margin: 0; font-size: 15px; }
.card-body { padding: 16px; }
.health-list { display: grid; gap: 12px; }
.health-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; }
.health-row span { color: var(--muted); }
.health-row strong { font-variant-numeric: tabular-nums; }
.meter { grid-column: 1/-1; height: 7px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
.meter i { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; white-space: nowrap; }
.badge.success { color: #74efc1; border-color: rgba(52, 211, 153, .35); background: rgba(52, 211, 153, .1); }
.badge.warning { color: #ffdc74; border-color: rgba(251, 191, 36, .35); background: rgba(251, 191, 36, .1); }
.badge.danger { color: #ff9aae; border-color: rgba(251, 113, 133, .4); background: rgba(251, 113, 133, .1); }
.badge.info { color: #8edaff; border-color: rgba(56, 189, 248, .35); background: var(--primary-soft); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.search { position: relative; min-width: 220px; }
.search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; }
.search input { padding-left: 38px; }
.table-wrap { overflow-x: auto; }
2026-08-04 09:04:50 +08:00
table { width: 100%; border-collapse: collapse; min-width: 960px; }
2026-08-04 00:23:55 +08:00
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { color: var(--muted); background: var(--surface-2); font-size: 12px; font-weight: 700; }
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: 10px; }
.camera-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); }
.device-name small { display: block; color: var(--muted); }
.camera-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.camera-card { overflow: hidden; }
.camera-stage { aspect-ratio: 16/9; position: relative; background: #020914; overflow: hidden; }
.camera-stage::before { content: ""; position: absolute; inset: 0; background: linear-gradient(160deg, transparent 45%, rgba(56,189,248,.08)), repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 4px); }
.scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.camera-meta { padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.camera-meta small { color: var(--muted); }
2026-08-04 09:04:50 +08:00
.breadcrumb { margin-bottom: 10px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.breadcrumb button { min-height: 32px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--primary); font-weight: 700; }
.breadcrumb button:hover { background: var(--primary-soft); }
.detail-tabs { margin-bottom: 12px; padding: 5px; display: flex; gap: 5px; overflow-x: auto; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }
.detail-tabs button { min-height: 40px; padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); white-space: nowrap; }
.detail-tabs button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -2px var(--primary); }
.detail-panel[hidden] { display: none; }
.context-strip { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(167,139,250,.38); border-radius: 10px; background: rgba(167,139,250,.1); }
.context-strip[hidden] { display: none; }
.context-strip p { margin: 0; color: var(--muted); }
.zone-layout { display: grid; grid-template-columns: minmax(420px, 1fr) 320px; gap: 12px; min-height: calc(100dvh - 260px); }
2026-08-04 00:23:55 +08:00
.zone-camera-list, .zone-settings, .zone-workspace { min-width: 0; }
.compact-list { display: grid; gap: 6px; padding: 10px; }
.compact-list button { width: 100%; min-height: 54px; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--text); display: flex; align-items: center; gap: 10px; padding: 8px; text-align: left; }
.compact-list button:hover, .compact-list button.selected { background: var(--primary-soft); border-color: rgba(56,189,248,.32); }
.compact-list small { display: block; color: var(--muted); }
.mini-thumb { width: 54px; height: 34px; border-radius: 6px; background: linear-gradient(145deg, #24364c, #0a101a); position: relative; overflow: hidden; }
.mini-thumb::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: 8px; height: 1px; background: var(--primary); transform: rotate(-12deg); }
.editor-tools { padding: 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tool-btn { min-width: 42px; min-height: 40px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.tool-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; }
.editor-stage { position: relative; aspect-ratio: 16/9; min-height: 340px; background: #030a12; overflow: hidden; }
.editor-stage svg { width: 100%; height: 100%; display: block; touch-action: none; }
.editor-hint { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; border-radius: 8px; background: rgba(3, 10, 18, .78); color: #dceaff; font-size: 12px; pointer-events: none; }
.editor-status { display: flex; justify-content: space-between; gap: 10px; padding: 10px 12px; color: var(--muted); border-top: 1px solid var(--line); font-size: 12px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-field { display: grid; gap: 6px; }
.form-field.full { grid-column: 1/-1; }
.form-field label { color: var(--muted); font-size: 12px; font-weight: 700; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 42px; }
.switch { position: relative; width: 42px; height: 24px; }
.switch input { position: absolute; opacity: 0; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: var(--surface-3); border: 1px solid var(--line); transition: .2s ease; }
.switch span::after { content: ""; position: absolute; width: 18px; height: 18px; left: 2px; top: 2px; border-radius: 50%; background: var(--muted); transition: transform .2s ease, background .2s ease; }
.switch input:checked + span { background: var(--primary-strong); border-color: var(--primary-strong); }
.switch input:checked + span::after { transform: translateX(18px); background: white; }
.coordinate-box { margin-top: 12px; padding: 12px; border: 1px dashed var(--line); border-radius: 10px; background: var(--surface-2); }
.coordinate-box h3 { margin: 0 0 4px; font-size: 13px; }
.coordinate-box p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
.coordinate-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; }
2026-08-04 09:04:50 +08:00
.linked-rules { margin-top: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.linked-rules h3 { margin: 0 0 4px; font-size: 13px; }
.linked-rules p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
.linked-rules ul { margin: 0 0 11px; padding-left: 18px; color: var(--muted); }
2026-08-04 00:23:55 +08:00
.settings-actions { position: sticky; bottom: 0; margin: 14px -16px -16px; padding: 12px 16px; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid var(--line); background: var(--surface); }
.drop-zone { padding: 28px; border: 1px dashed var(--line); border-radius: 12px; text-align: center; background: var(--surface-2); }
.drop-zone .icon { width: 28px; height: 28px; color: var(--primary); }
.drop-zone p { margin: 8px 0 12px; color: var(--muted); }
.system-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.system-tile { padding: 16px; }
.system-tile h3 { margin: 0 0 12px; font-size: 14px; }
.system-tile dl { margin: 0; display: grid; grid-template-columns: 1fr auto; gap: 8px; color: var(--muted); }
.system-tile dd { margin: 0; color: var(--text); font-variant-numeric: tabular-nums; }
.state-layer { display: none; min-height: 380px; place-items: center; border: 1px dashed var(--line); border-radius: var(--radius); background: var(--surface); text-align: center; padding: 24px; }
.state-layer.show { display: grid; }
.state-layer .icon { width: 38px; height: 38px; color: var(--primary); }
.state-layer h2 { margin: 12px 0 4px; font-size: 18px; }
.state-layer p { margin: 0 0 14px; color: var(--muted); max-width: 440px; }
.skeleton { width: min(420px, 90%); display: grid; gap: 10px; }
.skeleton i { display: block; height: 14px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-3), var(--line), var(--surface-3)); background-size: 200% 100%; animation: shimmer 1.4s infinite; }
.skeleton i:nth-child(2) { width: 70%; }
@keyframes shimmer { to { background-position: -200% 0; } }
dialog { width: min(680px, calc(100% - 28px)); max-height: 88dvh; border: 1px solid var(--line); border-radius: 16px; padding: 0; background: var(--surface); color: var(--text); box-shadow: 0 24px 80px rgba(0,0,0,.5); }
dialog::backdrop { background: rgba(0, 8, 18, .64); backdrop-filter: blur(4px); }
.dialog-head, .dialog-foot { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--line); }
.dialog-head h2 { margin: 0; font-size: 17px; }
.dialog-body { padding: 18px; overflow: auto; }
.dialog-foot { border: 0; border-top: 1px solid var(--line); justify-content: flex-end; }
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 1100; max-width: min(390px, calc(100% - 40px)); padding: 12px 14px; border: 1px solid rgba(52,211,153,.35); border-radius: 10px; background: #0b2c25; color: #d9fff3; box-shadow: var(--shadow); transform: translateY(20px); opacity: 0; pointer-events: none; transition: .2s ease; }
.toast.show { transform: none; opacity: 1; }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.mobile-nav { display: none; }
@media (max-width: 1180px) {
:root { --sidebar: 78px; }
.brand-copy, .nav span:not(.count), .nav-label, .sidebar-foot { display: none; }
.brand { justify-content: center; padding-inline: 0; }
.nav button { justify-content: center; padding: 0; }
.nav .count { position: absolute; margin: -24px 0 0 25px; }
2026-08-04 09:04:50 +08:00
.zone-layout { grid-template-columns: 1fr; }
.zone-settings { grid-column: auto; }
2026-08-04 00:23:55 +08:00
.settings-actions { position: static; margin-bottom: -16px; }
}
@media (max-width: 820px) {
:root { --sidebar: 0px; }
.app { display: block; padding-bottom: 66px; }
.sidebar { display: none; }
.topbar { padding: 8px 12px; }
.site-select { min-width: 0; }
.site-select span, .top-actions .select { display: none; }
.content { padding: 14px 12px; }
.stats, .system-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.split, .zone-layout { grid-template-columns: 1fr; }
2026-08-04 09:04:50 +08:00
.editor-stage { min-height: 0; }
.context-strip, .linked-zone { display: grid; }
2026-08-04 00:23:55 +08:00
.zone-camera-list { order: 2; }
.zone-workspace { order: 1; }
.zone-settings { order: 3; }
.camera-grid { grid-template-columns: 1fr; }
.mobile-nav { position: fixed; z-index: 50; left: 8px; right: 8px; bottom: 8px; display: grid; grid-template-columns: repeat(5, 1fr); padding: 5px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
2026-08-04 09:04:50 +08:00
.mobile-nav button { min-h…3676 tokens truncated…>
2026-08-04 00:23:55 +08:00
< 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 >
< div class = "editor-status" >< span id = "pointStatus" > 当前草稿:0 个点</ span >< span > 缩放 100% · 网格吸附关闭</ span ></ div >
</ section >
2026-08-04 09:04:50 +08:00
< aside class = "card zone-settings" >< div class = "card-head" >< h2 > 检测区域属性</ h2 >< button class = "btn btn-quiet icon-btn" aria-label = "更多区域操作" >< svg class = "icon" >< use href = "#i-more" /></ svg ></ button ></ div >< div class = "card-body" >
2026-08-04 00:23:55 +08:00
< div class = "form-grid" >
< div class = "form-field full" >< label for = "zoneName" > 名称</ label >< input id = "zoneName" class = "field" value = "北围墙危险区域" ></ div >
< div class = "form-field" >< label for = "zoneType" > 空间类型</ label >< select id = "zoneType" class = "select" >< option > 禁入区域</ option >< option > 方向警戒线</ option >< option > 关注区域</ option ></ select ></ div >
< div class = "form-field" >< label for = "zoneColor" > 叠加颜色</ label >< select id = "zoneColor" class = "select" >< option > 蓝色</ option >< option > 琥珀色</ option >< option > 红色</ option ></ select ></ div >
</ div >
< 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 >
2026-08-04 09:04:50 +08:00
< 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" > 在 Bell 配置规则</ a ></ div >
< div class = "settings-actions" >< button class = "btn" id = "discardDraft" > 放弃修改</ button >< button class = "btn btn-primary" id = "saveDraft" > 保存区域版本</ button ></ div >
2026-08-04 00:23:55 +08:00
</ div ></ aside >
</ div >
2026-08-04 09:04:50 +08:00
</ 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 >
2026-08-04 00:23:55 +08:00
</ 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 = "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 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 >
< section class = "view" data-page = "system" >
< div class = "page-head" >< div >< h1 > 系统状态</ h1 >< p > MediaMTX、数据库和对账器只是可观察组件,不替代设备台账真相。</ p ></ div >< div class = "page-actions" >< button class = "btn" >< svg class = "icon" >< use href = "#i-refresh" /></ svg > 刷新</ button ></ div ></ div >
< div class = "grid system-grid" >
< article class = "card system-tile" >< h3 > Sense 服务</ h3 >< dl >< dt > 状态</ dt >< dd >< span class = "badge success" > 健康</ span ></ dd >< dt > 运行时间</ dt >< dd > 12d 06h</ dd >< dt > 待处理队列</ dt >< dd > 2</ dd ></ dl ></ article >
< article class = "card system-tile" >< h3 > MediaMTX</ h3 >< dl >< dt > 实例</ dt >< dd > 2 / 2</ dd >< dt > Readers</ dt >< dd > 15</ dd >< dt > 重连</ dt >< dd > 1</ dd ></ dl ></ article >
< article class = "card system-tile" >< h3 > 本地资源</ h3 >< dl >< dt > CPU</ dt >< dd > 22%</ dd >< dt > 内存</ dt >< dd > 4.8 / 16 GB</ dd >< dt > 事件缓存</ dt >< dd > 86 MB</ dd ></ dl ></ article >
</ div >
</ section >
</ div >
</ main >
</ div >
< nav class = "mobile-nav" aria-label = "移动端主导航" >
2026-08-04 09:04:50 +08:00
< 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 >
2026-08-04 00:23:55 +08:00
</ nav >
< dialog id = "cameraDialog" aria-labelledby = "cameraDialogTitle" >
< form method = "dialog" >
< div class = "dialog-head" >< h2 id = "cameraDialogTitle" > 添加摄像头</ h2 >< button class = "btn btn-quiet icon-btn" value = "cancel" aria-label = "关闭对话框" > × </ button ></ div >
< div class = "dialog-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 = "select" 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 = "muted" 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 >
</ form >
</ dialog >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" ></ div >
< script >
const $ = ( selector , root = document ) => root . querySelector ( selector );
const $$ = ( selector , root = document ) => [... root . querySelectorAll ( selector )];
const toast = $ ( '#toast' );
let toastTimer ;
function notify ( message ) { toast . textContent = message ; toast . classList . add ( 'show' ); clearTimeout ( toastTimer ); toastTimer = setTimeout (() => toast . classList . remove ( 'show' ), 3600 ); }
const deviceData = [
2026-08-04 09:04:50 +08:00
[ '北围墙 01' , 'CAM-S2-001' , '启用' , '在线' , '刚刚' , '+0.2s' , 'media-01' , 'success' , '2 个区域' , 'success' ],
[ '北围墙 03' , 'CAM-S2-003' , '启用' , '重连中' , '2 分钟前' , '—' , 'media-01' , 'warning' , '待校准' , 'warning' ],
[ '实验楼入口 02' , 'CAM-S2-006' , '启用' , '在线' , '12 秒前' , '+3.8s' , 'media-02' , 'warning' , '1 个区域' , 'success' ],
[ '宿舍东通道' , 'CAM-S2-011' , '启用' , '在线' , '4 秒前' , '+0.1s' , 'media-02' , 'success' , '3 个区域' , 'success' ],
[ '配电房门口' , 'CAM-S2-015' , '停用' , '已停止' , '1 天前' , '—' , '未绑定' , 'info' , '未配置' , 'info' ]
2026-08-04 00:23:55 +08:00
];
2026-08-04 09:04:50 +08:00
$ ( '#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 ( '' );
2026-08-04 00:23:55 +08:00
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 ( '' );
function switchView ( name ) {
$$ ( '.view' ). forEach ( v => v . classList . toggle ( 'active' , v . dataset . page === name ));
2026-08-04 09:04:50 +08:00
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 ); });
2026-08-04 00:23:55 +08:00
$ ( '#stateSelect' ). value = 'normal' ; renderState ( 'normal' );
$ ( '#main-content' ). focus ({ preventScroll : true });
}
$$ ( '[data-view]' ). forEach ( b => b . addEventListener ( 'click' , () => switchView ( b . dataset . view )));
2026-08-04 09:04:50 +08:00
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` ;
$ ( '#configureRules' ). href = bellHref ; $ ( '#returnToBell' ). href = bellHref ;
switchView ( 'device-detail' );
}
$$ ( '[data-open-camera]' ). forEach ( b => b . addEventListener ( 'click' , () => openDeviceDetail ( b . dataset . openCamera )));
$ ( '#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 );
}));
2026-08-04 00:23:55 +08:00
function renderState ( state ) {
const layer = $ ( '#stateLayer' ); const views = $$ ( '.view' );
layer . classList . toggle ( 'show' , state !== 'normal' ); views . forEach ( v => v . style . display = state === 'normal' ? '' : 'none' );
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>` ,
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 ];
}
$ ( '#stateSelect' ). addEventListener ( 'change' , e => renderState ( e . target . value ));
$ ( '#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>' ;
2026-08-04 09:04:50 +08:00
$ ( '#draftBadge' ). className = points . length ? 'badge warning' : 'badge info' ;
$ ( '#draftBadge' ). textContent = points . length ? '区域有未保存修改' : '区域 v7' ;
2026-08-04 00:23:55 +08:00
}
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 (); });
$ ( '#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 ); });
2026-08-04 09:04:50 +08:00
$ ( '#discardDraft' ). addEventListener ( 'click' , () => { if ( ! points . length || confirm ( '放弃未保存的区域修改?' )) { points . length = 0 ; drawDraft (); notify ( '已放弃本次修改,已保存区域和 Bell 规则均未改变。' ); } });
$ ( '#saveDraft' ). addEventListener ( 'click' , () => { if (( activeTool === 'polygon' && points . length < 3 ) || ( activeTool === 'line' && points . length < 2 )) { notify ( activeTool === 'line' ? '警戒线至少需要 2 个点。' : '多边形至少需要 3 个点。' ); return ; } points . length = 0 ; drawDraft (); $ ( '#draftBadge' ). className = 'badge success' ; $ ( '#draftBadge' ). textContent = '区域 v8 已保存' ; notify ( '检测区域 v8 已保存;关联的 Bell 规则不会被自动发布。' ); });
$ ( '#simulateCalibration' ). addEventListener ( 'click' , () => { $ ( '#calibrationBadge' ). className = 'badge warning' ; $ ( '#calibrationBadge' ). textContent = '区域待校准' ; notify ( '画面分辨率或旋转发生变化,现有区域已标记为待校准。' ); });
2026-08-04 00:23:55 +08:00
const dialog = $ ( '#cameraDialog' );
$ ( '#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 ( '摄像头已保存为“待激活”,凭据不会显示在列表中。' ));
$ ( '#fakeUpload' ). addEventListener ( 'click' , () => notify ( '演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。' ));
drawDraft ();
2026-08-04 09:04:50 +08:00
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 ;
}
2026-08-04 00:23:55 +08:00
</ script >
</ body >
</ html >
2026-08-04 09:04:50 +08:00