Files
yovision/docs/design/sense/index.html
T
QiuSW e18a1ea83b
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
docs(design): apply Sense prototype review
2026-08-04 10:05:56 +08:00

690 lines
77 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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: #8498b0;
--line: #27384d;
--primary: #38bdf8;
--primary-strong: #0369a1;
--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: #596c82;
--primary: #0369a1;
--primary-strong: #075985;
--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: 44px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px;
}
.textarea { min-height: 74px; resize: vertical; }
.top-actions .select { width: auto; min-width: 126px; }
.btn {
min-height: 44px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text);
display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; text-decoration: none; transition: border-color .2s ease, background .2s ease, opacity .2s ease;
}
.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: 44px; 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; }
table { width: 100%; border-collapse: collapse; min-width: 1040px; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { 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: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); }
.camera-icon.radar { color: var(--warning); }
.capabilities { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; }
.capability { color: var(--muted); font-size: 11px; }
.pagination { padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; gap: 12px; border-top: 1px solid var(--line); }
.pagination-actions { display: flex; gap: 6px; }
.device-name small { display: block; color: var(--muted); }
.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); }
.breadcrumb { margin-bottom: 10px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.breadcrumb button { min-height: 44px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--primary); font-weight: 700; }
.breadcrumb button:hover { background: var(--primary-soft); }
.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: 44px; padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); white-space: nowrap; }
.detail-tabs button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -2px var(--primary); }
.detail-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); }
.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: 44px; min-height: 44px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.tool-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; }
.point-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 6px; }
.point-item { display: grid; grid-template-columns: auto 1fr 1fr auto; align-items: center; gap: 6px; }
.point-item .field { min-width: 0; }
.point-index { color: var(--muted); font-size: 12px; }
.notice { margin: 12px 0 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--muted); }
.notice.warning { border-color: rgba(251,191,36,.38); background: rgba(251,191,36,.08); color: #ffe39a; }
.operation-grid { display: grid; gap: 8px; }
.operation-option { min-height: 72px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); display: grid; grid-template-columns: auto 1fr; gap: 10px; align-items: start; cursor: pointer; }
.operation-option:has(input:checked) { border-color: var(--primary); background: var(--primary-soft); }
.operation-option strong, .operation-option small { display: block; }
.operation-option small { color: var(--muted); }
.linked-rules { margin-top: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.linked-rules 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); }
.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; }
.zone-layout { grid-template-columns: 1fr; }
.zone-settings { grid-column: auto; }
.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; }
.editor-stage { min-height: 0; }
.context-strip, .linked-zone { display: grid; }
.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); }
.mobile-nav button { min-height: 48px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); display: grid; place-items: center; font-size: 11px; }
.mobile-nav button.active { color: var(--primary); background: var(--primary-soft); }
}
@media (max-width: 560px) {
.stats, .system-grid { grid-template-columns: 1fr; }
.page-head { display: grid; }
.page-actions { width: 100%; }
.page-actions .btn { flex: 1; }
.form-grid { grid-template-columns: 1fr; }
.form-field.full { grid-column: auto; }
.editor-stage { min-height: 0; }
.coordinate-row { grid-template-columns: 1fr 1fr; }
.coordinate-row .btn { grid-column: 1/-1; }
.point-item { grid-template-columns: auto 1fr 1fr; }
.point-item .btn { grid-column: 2/-1; }
input, select, textarea { font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<svg aria-hidden="true" width="0" height="0" style="position:absolute">
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></symbol>
<symbol id="i-video" viewBox="0 0 24 24"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M5 7h3l1.4-2h5.2L16 7h3a2 2 0 0 1 2 2v9H3V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="12.5" r="3"/></symbol>
<symbol id="i-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4m8.4-8.4a6 6 0 0 1 0 8.4M4.6 4.6a10.5 10.5 0 0 0 0 14.8m14.8-14.8a10.5 10.5 0 0 1 0 14.8"/></symbol>
<symbol id="i-zone" viewBox="0 0 24 24"><path d="m5 5 14 2-2 12-12-3Z"/><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="7" r="1.5"/><circle cx="17" cy="19" r="1.5"/><circle cx="5" cy="16" r="1.5"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M4 15v5h16v-5"/></symbol>
<symbol id="i-pulse" viewBox="0 0 24 24"><path d="M3 12h4l2-6 4 12 2-6h6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/><path d="M10 21h4"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="i-pointer" viewBox="0 0 24 24"><path d="m5 3 14 8-6 2-3 6Z"/></symbol>
<symbol id="i-line" viewBox="0 0 24 24"><path d="m4 18 16-12"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="6" r="2"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M9 7 4 12l5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3m-9 0 1 14h10l1-14"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 3 2 20h20Z"/><path d="M12 9v4m0 3v.01"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 6v5h-5M4 18v-5h5"/><path d="M18 9a7 7 0 0 0-12-2M6 15a7 7 0 0 0 12 2"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></symbol>
<symbol id="i-wifi" viewBox="0 0 24 24"><path d="M4 9a12 12 0 0 1 16 0M7 13a8 8 0 0 1 10 0m-7 4a3 3 0 0 1 4 0M12 20v.01"/></symbol>
</svg>
<div class="prototype-banner">PROTOTYPE - 仅供枚举交互,非实现依据</div>
<div class="app">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand"><div class="brand-mark">Y</div><div class="brand-copy"><strong>YoVision</strong><small>Sense · Edge Console</small></div></div>
<div class="nav-label">Workspace</div>
<nav class="nav">
<button data-view="overview" aria-label="运行总览"><svg class="icon"><use href="#i-grid"/></svg><span>运行总览</span></button>
<button data-view="monitor" aria-label="实时监控,当前 4 路"><svg class="icon"><use href="#i-video"/></svg><span>实时监控</span><span class="count">4</span></button>
<button data-view="devices" aria-current="page" aria-label="设备,共 17 台"><svg class="icon"><use href="#i-camera"/></svg><span>设备</span><span class="count">17</span></button>
<button data-view="onboarding" aria-label="接入任务,1 个进行中"><svg class="icon"><use href="#i-upload"/></svg><span>接入任务</span><span class="count">1</span></button>
<button data-view="system" aria-label="系统状态"><svg class="icon"><use href="#i-pulse"/></svg><span>系统状态</span></button>
</nav>
<div class="sidebar-foot"><div class="online-row"><span class="dot"></span><strong>边缘节点在线</strong></div><div class="copy">sense-edge-01<br><span class="mono">v0.1-prototype</span></div></div>
</aside>
<main class="main" id="main-content" tabindex="-1">
<header class="topbar">
<div class="site-select"><div><strong>青藤寄宿学校</strong><span> / 主校区 · 视频 16 / 16 路 · 设备 17 台</span></div><svg class="icon"><use href="#i-chevron"/></svg></div>
<div class="top-actions">
<select class="select" id="stateSelect" aria-label="原型状态演示">
<option value="normal">正常状态</option><option value="loading">加载中</option><option value="empty">空数据</option><option value="error">连接失败</option>
</select>
<button class="btn icon-btn" id="themeToggle" aria-label="切换明暗主题" title="切换明暗主题"><svg class="icon"><use href="#i-sun"/></svg></button>
<button class="btn icon-btn" aria-label="查看运维通知" title="查看运维通知"><svg class="icon"><use href="#i-bell"/></svg></button>
</div>
</header>
<div class="content">
<section class="state-layer" id="stateLayer" aria-live="polite"></section>
<section class="view" data-page="overview">
<div class="page-head"><div><h1>运行总览</h1><p>先判断是否影响业务,再进入设备或分片定位原因。</p></div><div class="page-actions"><button class="btn"><svg class="icon"><use href="#i-refresh"/></svg>立即对账</button></div></div>
<div class="grid stats">
<article class="card stat"><div class="stat-top"><span>在线设备</span><svg class="icon"><use href="#i-camera"/></svg></div><strong>16 / 17</strong><span class="trend">94.1% · 1 路视频重连中</span></article>
<article class="card stat"><div class="stat-top"><span>活跃媒体流</span><svg class="icon"><use href="#i-video"/></svg></div><strong>15</strong><span class="trend">主/子码流均衡</span></article>
<article class="card stat"><div class="stat-top"><span>待收敛项</span><svg class="icon"><use href="#i-refresh"/></svg></div><strong>2</strong><span class="muted">配置 1 · 时间漂移 1</span></article>
<article class="card stat"><div class="stat-top"><span>今日设备告警</span><svg class="icon"><use href="#i-alert"/></svg></div><strong>3</strong><span class="muted">与业务预警分开</span></article>
</div>
<div class="grid split">
<article class="card"><div class="card-head"><h2>分片健康</h2><span class="badge success">全部可用</span></div><div class="card-body health-list">
<div class="health-row"><span>当前媒体分片 media-01</span><strong>16 / 32 路</strong><div class="meter"><i style="width:50%"></i></div></div>
<div class="health-row"><span>分片规模</span><strong>当前 1 / 最多 4</strong><div class="meter"><i style="width:25%"></i></div></div>
<div class="health-row"><span>推理配置收敛</span><strong>16 / 16 路</strong><div class="meter"><i style="width:100%;background:var(--success)"></i></div></div>
</div></article>
<article class="card"><div class="card-head"><h2>需要处理</h2><span class="badge warning">2 项</span></div><div class="card-body">
<p><strong>北围墙 03</strong><br><span class="muted">断线 02:14 · 正在第 4 次重连</span></p>
<p><strong>实验楼入口 02</strong><br><span class="muted">时间漂移 +3.8s · 建议重新校时</span></p>
</div></article>
</div>
</section>
<section class="view" data-page="monitor">
<div class="page-head"><div><h1>实时监控</h1><p>默认只并发预览 4 路子码流;切页会释放上一页播放器。</p></div><div class="page-actions"><span class="badge info">第 1 / 4 页</span><button class="btn">布局 2 × 2</button></div></div>
<div class="grid camera-grid" id="cameraGrid"></div>
</section>
<section class="view active" data-page="devices">
<div class="page-head"><div><h1>设备</h1><p>统一设备台账按类型与能力组织;期望态、实际态和连接信息相互独立。</p></div><div class="page-actions"><button class="btn">发现设备</button><button class="btn btn-primary" id="openDeviceDialog"><svg class="icon"><use href="#i-plus"/></svg>添加设备</button></div></div>
<article class="card">
<div class="card-head"><div class="toolbar"><div class="search"><svg class="icon"><use href="#i-search"/></svg><input class="field" id="deviceSearch" aria-label="搜索设备" placeholder="名称、序列号或点位"></div><select class="select" id="modalityFilter" aria-label="设备类型筛选"><option value="all">全部类型</option><option value="video">视频</option><option value="radar">雷达</option></select><select class="select" id="statusFilter" aria-label="设备状态筛选"><option value="all">全部状态</option><option value="exception">异常</option><option value="online">在线</option></select></div><span class="muted">视频配额 16 / 128 · 其他设备不占视频路数</span></div>
<div class="table-wrap"><table><thead><tr><th><input type="checkbox" aria-label="选择当前页全部设备"></th><th>设备</th><th>类型与能力</th><th>期望态</th><th>实际态</th><th>最后遥测</th><th>连接适配器</th><th>健康摘要</th><th>配置</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table></div>
<div class="pagination"><span id="devicePageSummary" class="muted">第 1 页 · 共 17 台设备</span><div class="pagination-actions"><button class="btn" id="previousDevicePage" disabled>上一页</button><button class="btn" id="nextDevicePage">下一页</button></div></div>
</article>
</section>
<section class="view" data-page="device-detail">
<div class="context-strip" id="ruleReturnContext" hidden><p><strong>来自 Bell 规则草稿:</strong>已保留摄像头与区域上下文;保存空间坐标后可返回继续配置规则。</p><a class="btn" id="returnToBell" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=sense-device">返回规则草稿</a></div>
<div class="breadcrumb"><button id="backToDevices">设备</button><span>/</span><span id="deviceBreadcrumb">北围墙 01</span></div>
<div class="page-head"><div><h1 id="deviceDetailName">北围墙 01</h1><p><span id="deviceDetailSerial" class="mono">CAM-S2-001</span> · <span id="deviceDetailDescription">视频接入、画面与空间配置。</span></p></div><div class="page-actions"><span class="badge success" id="calibrationBadge">区域已校准</span><button class="btn" id="simulateCalibration">模拟画面参数变化</button><button class="btn btn-danger" id="openOperationDialog">停止或隔离…</button></div></div>
<div class="detail-tabs" role="tablist" aria-label="设备详情">
<button id="tab-info" role="tab" data-detail-tab="info" aria-controls="panel-info" aria-selected="false" tabindex="-1">基本信息</button>
<button id="tab-zones" class="active" role="tab" data-detail-tab="zones" aria-controls="panel-zones" aria-selected="true" tabindex="0">画面与检测区域</button>
<button id="tab-health" role="tab" data-detail-tab="health" aria-controls="panel-health" aria-selected="false" tabindex="-1">连接与健康</button>
<button id="tab-history" role="tab" data-detail-tab="history" aria-controls="panel-history" aria-selected="false" tabindex="-1">操作记录</button>
</div>
<section id="panel-info" class="detail-panel" role="tabpanel" aria-labelledby="tab-info" data-detail-panel="info" hidden tabindex="0">
<div class="grid split"><article class="card"><div class="card-head"><h2>设备信息</h2><span class="badge success" id="detailActualBadge">在线</span></div><div class="card-body"><p><strong>点位:</strong><span id="detailArea">北围墙</span></p><p><strong>类型:</strong><span id="detailModality">视频设备</span></p><p><strong>能力:</strong><span id="detailCapabilities">图像采集、视频流、区域坐标</span></p><p><strong>协议:</strong><span id="detailProtocol">ONVIF / RTSP</span></p><p><strong>凭据:</strong>已安全保存,页面不可见</p></div></article><article class="card"><div class="card-head"><h2>期望态</h2><span class="badge info">启用</span></div><div class="card-body"><p><strong>连接适配器:</strong><span id="detailAdapter">media-01</span></p><p id="detailProfile">Profile:子码流 704 × 576 · 5 FPS</p><p class="muted">设备类型决定可用功能,能力字段决定页面与操作,不按名称猜测。</p></div></article></div>
</section>
<section id="panel-zones" class="detail-panel" role="tabpanel" aria-labelledby="tab-zones" data-detail-panel="zones" tabindex="0">
<div class="page-head"><div><h2 style="margin:0 0 4px">画面与检测区域</h2><p>区域依附当前摄像头坐标系;这里只保存空间几何,不发布告警规则。</p></div><div class="page-actions"><span class="badge info" id="draftBadge">区域 v7</span><button class="btn">区域版本记录</button></div></div>
<div class="zone-layout">
<section class="card zone-workspace" aria-label="区域绘制工作区">
<div class="editor-tools" role="toolbar" aria-label="区域绘制工具">
<button class="tool-btn" data-tool="pointer" aria-pressed="false" title="选择对象"><svg class="icon"><use href="#i-pointer"/></svg><span>选择</span></button>
<button class="tool-btn active" data-tool="polygon" aria-pressed="true" title="绘制多边形"><svg class="icon"><use href="#i-zone"/></svg><span>多边形</span></button>
<button class="tool-btn" data-tool="line" aria-pressed="false" title="绘制方向警戒线"><svg class="icon"><use href="#i-line"/></svg><span>警戒线</span></button>
<span class="tool-divider"></span>
<button class="tool-btn" id="undoPoint" title="撤销最后一个点"><svg class="icon"><use href="#i-undo"/></svg><span>撤销</span></button>
<button class="tool-btn" id="finishDrawing" title="显式完成当前图形"><svg class="icon"><use href="#i-check"/></svg><span>完成绘制</span></button>
<button class="tool-btn" id="reverseLine" title="反转警戒线方向" hidden><svg class="icon"><use href="#i-refresh"/></svg><span>反转方向</span></button>
<button class="tool-btn" id="clearPoints" title="清空当前草稿"><svg class="icon"><use href="#i-trash"/></svg><span>清空</span></button>
</div>
<div class="editor-stage">
<svg id="zoneCanvas" viewBox="0 0 960 540" role="img" aria-label="北围墙 01 子码流快照及区域叠加;可点击添加多边形顶点">
<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#10283b"/><stop offset="1" stop-color="#07111d"/></linearGradient><pattern id="scan" width="8" height="8" patternUnits="userSpaceOnUse"><path d="M0 8h8" stroke="#ffffff" stroke-opacity=".035"/></pattern><marker id="draftArrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L0,6 L9,3 z" fill="#fbbf24"/></marker></defs>
<rect width="960" height="540" fill="url(#sky)"/><path d="M0 330 160 250 330 315 510 235 690 310 820 225 960 282V540H0Z" fill="#13283a"/><rect y="410" width="960" height="130" fill="#101a22"/><path d="M0 414h960M0 472h960" stroke="#5c7489" stroke-opacity=".45"/><path d="M170 300v240m230-245v245m250-250v250m190-280v280" stroke="#6f879b" stroke-width="4"/><path d="M0 345h960" stroke="#7890a4" stroke-width="5"/><rect width="960" height="540" fill="url(#scan)"/>
<polygon points="105,365 285,310 535,335 505,466 135,472" fill="#38bdf8" fill-opacity=".18" stroke="#38bdf8" stroke-width="4" stroke-dasharray="10 7"/>
<g fill="#07111d" stroke="#7dd3fc" stroke-width="4"><circle cx="105" cy="365" r="7"/><circle cx="285" cy="310" r="7"/><circle cx="535" cy="335" r="7"/><circle cx="505" cy="466" r="7"/><circle cx="135" cy="472" r="7"/></g>
<line x1="650" y1="340" x2="855" y2="430" stroke="#fbbf24" stroke-width="5"/><path d="m760 382 30-2-18 24" fill="none" stroke="#fbbf24" stroke-width="5"/><g id="draftLayer"></g>
<text x="26" y="38" fill="#d6e8f5" font-size="18" font-family="sans-serif">北围墙 01 · 子码流预览</text><text x="26" y="66" fill="#88a4b8" font-size="14" font-family="sans-serif">CAM-S2-001 · 704 × 576 · 5 FPS</text>
</svg>
<div class="editor-hint" id="editorHint">多边形:点击画面添加顶点,再选择“完成绘制”;也可在右侧输入坐标。</div>
</div>
<div class="editor-status"><span id="pointStatus">当前草稿:0 个点</span><span>缩放 100% · 网格吸附关闭</span></div>
</section>
<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">
<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="point-list" aria-live="polite"><li class="muted">尚未添加草稿点</li></ol></div>
<div class="notice" id="autosaveStatus" role="status">草稿会自动保存在此浏览器会话中。</div>
<div class="linked-rules"><h3>关联的告警规则</h3><p>规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。</p><ul><li>非授权人员进入 · 正式生效</li><li>区域内聚集 · 试运行</li></ul><a class="btn" id="configureRules" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007&amp;return_to=sense-device">在 Bell 配置规则</a></div>
<div class="settings-actions"><button class="btn" id="simulateConflict">模拟版本冲突</button><button class="btn" id="discardDraft">放弃修改</button><button class="btn btn-primary" id="saveDraft">保存区域版本</button></div>
</div></aside>
</div>
</section>
<section id="panel-health" class="detail-panel" role="tabpanel" aria-labelledby="tab-health" data-detail-panel="health" hidden tabindex="0"><div class="grid system-grid"><article class="card system-tile"><h3 id="connectionHealthTitle">媒体连接</h3><dl><dt>实际态</dt><dd><span class="badge success">在线</span></dd><dt>适配器</dt><dd id="healthAdapter">media-01</dd><dt>重连</dt><dd>0</dd></dl></article><article class="card system-tile"><h3>设备时间</h3><dl><dt>漂移</dt><dd id="healthClock">+0.2s</dd><dt>最近校时</dt><dd>5m 前</dd></dl></article><article class="card system-tile" id="calibrationTile"><h3>区域校准</h3><dl><dt>坐标版本</dt><dd>v7</dd><dt>画面参数</dt><dd>匹配</dd></dl></article></div></section>
<section id="panel-history" class="detail-panel" role="tabpanel" aria-labelledby="tab-history" data-detail-panel="history" hidden tabindex="0"><article class="card"><div class="card-head"><h2>操作记录</h2><span class="badge info">每次请求均审计</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>操作</th><th>对象</th><th>执行人</th><th>结果</th></tr></thead><tbody id="historyRows"><tr><td>22:52</td><td>保存检测区域</td><td>ZONE-007 · v7</td><td>实施工程师</td><td><span class="badge success">成功</span></td></tr><tr><td>22:41</td><td>校验设备能力</td><td>CAM-S2-001</td><td>系统</td><td><span class="badge success">匹配</span></td></tr></tbody></table></div></article></section>
</section>
<section class="view" data-page="onboarding">
<div class="page-head"><div><h1>接入任务</h1><p>默认 16 路视频也支持批量导入;扩至 128 路时不依赖逐路手工操作,非视频设备走对应适配器。</p></div><div class="page-actions"><button class="btn">下载 CSV 模板</button></div></div>
<div class="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="移动端主导航">
<button data-view="overview">总览</button><button data-view="monitor">监控</button><button class="active" data-view="devices" aria-current="page">设备</button><button data-view="onboarding">接入</button><button data-view="system">系统</button>
</nav>
<dialog id="deviceDialog" aria-labelledby="deviceDialogTitle">
<form method="dialog">
<div class="dialog-head"><h2 id="deviceDialogTitle">添加设备</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭添加设备对话框">×</button></div>
<div class="dialog-body"><div class="form-grid">
<div class="form-field"><label for="deviceModality">设备类型</label><select class="select" id="deviceModality"><option value="video">视频设备</option><option value="radar">毫米波雷达</option></select></div>
<div class="form-field"><label for="deviceArea">所属区域</label><select class="select" id="deviceArea"><option value="north" data-capture-policy="video_allowed">北围墙 · 允许成像</option><option value="lab" data-capture-policy="video_allowed">实验楼 · 允许成像</option><option value="dorm" data-capture-policy="video_allowed">宿舍区 · 允许成像</option><option value="privacy" data-capture-policy="non_imaging_only">心理辅导室 · 仅非成像设备</option></select></div>
<div class="form-field"><label for="deviceName">名称</label><input class="field" id="deviceName" required value="北围墙 04"></div>
<div class="form-field"><label for="deviceSerial">序列号</label><input class="field" id="deviceSerial" value="CAM-DEMO-004"></div>
<div class="form-field"><label for="deviceProtocol">协议</label><select class="select" id="deviceProtocol"><option value="onvif">ONVIF</option><option value="rtsp">RTSP</option><option value="mqtt">MQTT</option></select></div>
<div class="form-field full"><label for="deviceEndpoint">设备地址</label><input class="field" id="deviceEndpoint" value="192.0.2.24:80" aria-describedby="endpointHelp"><small id="endpointHelp" class="muted">原型使用保留地址;保存后列表仅显示脱敏端点。</small></div>
<div class="form-field video-field"><label for="deviceUser">用户名</label><input class="field" id="deviceUser" value="operator"></div>
<div class="form-field video-field"><label for="devicePassword">密码</label><input class="field" id="devicePassword" type="password" value="prototype-only"></div>
<div class="form-field radar-field" hidden><label for="radarTopic">遥测主题</label><input class="field" id="radarTopic" value="sense/radar/demo"></div>
<div class="form-field radar-field" hidden><label for="radarInterval">上报周期</label><select class="select" id="radarInterval"><option>1 秒</option><option>5 秒</option><option>10 秒</option></select></div>
</div><div class="notice" id="privacyAdmission" role="status">当前区域允许图像采集;保存时仍会再次校验区域策略。</div><p><button type="button" class="btn" id="testConnection">测试连接</button> <span id="testResult" class="muted" aria-live="polite">保存前验证设备声明能力、连接与校时。</span></p></div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-primary" id="saveDevice">保存为待激活</button></div>
</form>
</dialog>
<dialog id="operationDialog" aria-labelledby="operationDialogTitle">
<form method="dialog">
<div class="dialog-head"><h2 id="operationDialogTitle">停止或隔离设备</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭停止或隔离对话框">×</button></div>
<div class="dialog-body">
<p>目标:<strong id="operationDeviceName">北围墙 01</strong> <span id="operationDeviceSerial" class="mono">CAM-S2-001</span></p>
<div class="operation-grid" role="radiogroup" aria-label="选择操作">
<label class="operation-option"><input type="radio" name="deviceOperation" value="pause-inference" checked><span><strong>暂停推理</strong><small>保留媒体接入与实时预览,只停止新推理任务;已产生事件不删除。</small></span></label>
<label class="operation-option"><input type="radio" name="deviceOperation" value="disable-ingest"><span><strong>停用接入</strong><small>写入期望态“停用”,等待适配器停止拉流或遥测;会影响实时监控。</small></span></label>
<label class="operation-option"><input type="radio" name="deviceOperation" value="kick-session"><span><strong>断开当前会话</strong><small>只终止当前连接,期望态仍为启用,设备可能自动重连。</small></span></label>
</div>
<div class="notice warning" id="operationImpact">影响:停止新推理;媒体连接保持在线。执行后将显示期望态与实际态的收敛过程。</div>
<div class="notice" id="operationProgress" role="status" aria-live="polite">尚未执行。重复提交不会产生重复副作用,但每次请求都会记录审计。</div>
</div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button type="button" class="btn btn-danger" id="confirmOperation">确认执行</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<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 = [
{name:'北围墙 01',serial:'CAM-S2-001',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'刚刚',adapter:'media-01',health:'时钟 +0.2s',config:'2 个区域',area:'北围墙',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.2s'},
{name:'北围墙 03',serial:'CAM-S2-003',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'重连中',status:'warning',last:'2 分钟前',adapter:'media-01',health:'第 4 次重连',config:'待校准',area:'北围墙',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'未知'},
{name:'实验楼入口 02',serial:'CAM-S2-006',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'12 秒前',adapter:'media-01',health:'时钟 +3.8s',config:'1 个区域',area:'实验楼',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+3.8s'},
{name:'宿舍东通道',serial:'CAM-S2-011',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:'4 秒前',adapter:'media-01',health:'时钟 +0.1s',config:'3 个区域',area:'宿舍区',protocol:'RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.1s'},
{name:'操场东侧雷达',serial:'RAD-S2-001',modality:'radar',modalityLabel:'毫米波雷达',capabilities:['目标距离','速度','方位角'],expected:'启用',actual:'在线',status:'success',last:'1 秒前',adapter:'mqtt-01',health:'丢包 0.1%',config:'1 Hz 上报',area:'操场',protocol:'MQTT',profile:'遥测主题 sense/radar/east · 1 Hz',clock:'+0.3s'},
{name:'配电房门口',serial:'CAM-S2-015',modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流'],expected:'停用',actual:'已停止',status:'info',last:'1 天前',adapter:'未绑定',health:'期望态已收敛',config:'未配置',area:'配电房',protocol:'ONVIF',profile:'尚未选择 Profile',clock:'—'},
...Array.from({length:11},(_,i)=>({name:`教学楼通道 ${String(i+1).padStart(2,'0')}`,serial:`CAM-S2-${String(i+20).padStart(3,'0')}`,modality:'video',modalityLabel:'视频',capabilities:['图像采集','视频流','区域坐标'],expected:'启用',actual:'在线',status:'success',last:`${i+2} 秒前`,adapter:'media-01',health:'健康',config:i%3===0?'1 个区域':'未配置',area:'教学楼',protocol:'ONVIF / RTSP',profile:'子码流 704 × 576 · 5 FPS',clock:'+0.2s'}))
];
const pageSize = 5;
let devicePage = 1;
let currentDevice = deviceData[0];
function filteredDevices() {
const term = $('#deviceSearch').value.trim().toLowerCase();
const modality = $('#modalityFilter').value;
const status = $('#statusFilter').value;
return deviceData.filter(device => {
const termMatch = !term || `${device.name} ${device.serial} ${device.area}`.toLowerCase().includes(term);
const modalityMatch = modality === 'all' || device.modality === modality;
const statusMatch = status === 'all' || (status === 'exception' ? device.status === 'warning' : device.actual === '在线');
return termMatch && modalityMatch && statusMatch;
});
}
function renderDevices() {
const filtered = filteredDevices();
const pages = Math.max(1, Math.ceil(filtered.length / pageSize));
devicePage = Math.min(devicePage, pages);
const start = (devicePage - 1) * pageSize;
const rows = filtered.slice(start, start + pageSize);
$('#deviceRows').innerHTML = rows.length ? rows.map(device => {
const icon = device.modality === 'radar' ? 'i-radar' : 'i-camera';
return `<tr><td><input type="checkbox" aria-label="选择 ${device.name}"></td><td><div class="device-name"><span class="camera-icon ${device.modality}"><svg class="icon"><use href="#${icon}"/></svg></span><span><strong>${device.name}</strong><small>${device.serial} · ${device.area}</small></span></div></td><td><span class="badge info">${device.modalityLabel}</span><div class="capabilities">${device.capabilities.map(c=>`<span class="capability">${c}</span>`).join('<span aria-hidden="true">·</span>')}</div></td><td>${device.expected}</td><td><span class="badge ${device.status}">${device.actual}</span></td><td>${device.last}</td><td>${device.adapter}</td><td>${device.health}</td><td>${device.config}</td><td><div class="toolbar"><button class="btn" data-open-device="${device.serial}">详情</button><button class="btn" data-device-operation="${device.serial}" aria-label="停止或隔离 ${device.name}">操作…</button></div></td></tr>`;
}).join('') : '<tr><td colspan="10"><div class="drop-zone"><strong>没有匹配的设备</strong><p>调整类型、状态或搜索条件。</p></div></td></tr>';
$('#devicePageSummary').textContent = `第 ${devicePage} / ${pages} 页 · 共 ${filtered.length} 台设备`;
$('#previousDevicePage').disabled = devicePage <= 1;
$('#nextDevicePage').disabled = devicePage >= pages;
}
['deviceSearch','modalityFilter','statusFilter'].forEach(id => $(`#${id}`).addEventListener(id === 'deviceSearch' ? 'input' : 'change', () => { devicePage=1; renderDevices(); }));
$('#previousDevicePage').addEventListener('click',()=>{ devicePage--; renderDevices(); });
$('#nextDevicePage').addEventListener('click',()=>{ devicePage++; renderDevices(); });
const cameraNames = ['北围墙 01','实验楼入口 02','宿舍东通道','配电房门口'];
$('#cameraGrid').innerHTML = cameraNames.map((name, i) => `<article class="card camera-card"><div class="camera-stage"><svg class="scene" viewBox="0 0 640 360" role="img" aria-label="${name} 模拟子码流画面"><rect width="640" height="360" fill="#07111d"/><path d="M0 ${250-i*12} 140 180 310 230 460 150 640 210V360H0Z" fill="#14283a"/><path d="M0 290h640M100 210v150m210-165v165m220-190v190" stroke="#647b8e" stroke-width="4"/><rect width="640" height="360" fill="none" stroke="#ffffff" stroke-opacity=".08"/><text x="18" y="28" fill="#d7e7f3" font-size="16">${name} · 子码流</text><circle cx="598" cy="27" r="5" fill="#34d399"/></svg></div><div class="camera-meta"><span><strong>${name}</strong><small>704 × 576 · 5 FPS</small></span><span class="badge success">在线</span></div></article>`).join('');
function switchView(name) {
$$('.view').forEach(v => v.classList.toggle('active', v.dataset.page === name));
const navName = name === 'device-detail' ? 'devices' : name;
$$('[data-view]').forEach(b => { const on = b.dataset.view === navName; if (on) b.setAttribute('aria-current','page'); else b.removeAttribute('aria-current'); b.classList.toggle('active', on); });
$('#stateSelect').value = 'normal'; renderState('normal');
$('#main-content').focus({preventScroll:true});
}
$$('[data-view]').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
function activateDetailTab(name, focus = false) {
const visibleTabs = $$('.detail-tabs [role="tab"]:not([hidden])');
const selected = visibleTabs.find(tab => tab.dataset.detailTab === name) || visibleTabs[0];
$$('.detail-tabs [role="tab"]').forEach(tab => { const on = tab === selected; tab.classList.toggle('active', on); tab.setAttribute('aria-selected', String(on)); tab.tabIndex = on ? 0 : -1; });
$$('[data-detail-panel]').forEach(panel => panel.hidden = panel.dataset.detailPanel !== selected.dataset.detailTab);
if (focus) selected.focus();
}
function openDeviceDetail(serial = 'CAM-S2-001') {
const device = deviceData.find(d => d.serial === serial) || deviceData[0];
currentDevice = device;
$('#deviceDetailName').textContent = device.name; $('#deviceBreadcrumb').textContent = device.name; $('#deviceDetailSerial').textContent = device.serial;
$('#deviceDetailDescription').textContent = device.modality === 'video' ? '视频接入、画面与空间配置。' : '遥测接入、能力与健康配置。';
$('#detailArea').textContent=device.area; $('#detailModality').textContent=device.modalityLabel; $('#detailCapabilities').textContent=device.capabilities.join('、'); $('#detailProtocol').textContent=device.protocol; $('#detailAdapter').textContent=device.adapter; $('#detailProfile').textContent=device.profile;
$('#detailActualBadge').textContent=device.actual; $('#detailActualBadge').className=`badge ${device.status}`; $('#healthAdapter').textContent=device.adapter; $('#healthClock').textContent=device.clock;
const video = device.capabilities.includes('图像采集');
$('#tab-zones').hidden=!video; $('#calibrationBadge').hidden=!video; $('#simulateCalibration').hidden=!video; $('#calibrationTile').hidden=!video;
$('#connectionHealthTitle').textContent = video ? '媒体连接' : '遥测连接';
const requestedZone = new URLSearchParams(location.search).get('zone') || 'ZONE-007';
const bellHref = `../bell/index.html?view=rules&camera=${encodeURIComponent(device.serial)}&zone=${encodeURIComponent(requestedZone)}&return_to=sense-device`;
$('#configureRules').href = bellHref; $('#returnToBell').href = bellHref;
if (video) restoreDraft(device.serial); else { points.length=0; drawDraft({persist:false}); }
activateDetailTab(video ? 'zones' : 'info');
switchView('device-detail');
}
$('#deviceRows').addEventListener('click', event => { const detail=event.target.closest('[data-open-device]'); const operation=event.target.closest('[data-device-operation]'); if (detail) openDeviceDetail(detail.dataset.openDevice); if (operation) openOperationDialog(operation.dataset.deviceOperation); });
$('#backToDevices').addEventListener('click', () => switchView('devices'));
$$('.detail-tabs [role="tab"]').forEach(tab => {
tab.addEventListener('click',()=>activateDetailTab(tab.dataset.detailTab));
tab.addEventListener('keydown',event=>{ const tabs=$$('.detail-tabs [role="tab"]:not([hidden])'); const index=tabs.indexOf(tab); let next=index; if(event.key==='ArrowRight') next=(index+1)%tabs.length; else if(event.key==='ArrowLeft') next=(index-1+tabs.length)%tabs.length; else if(event.key==='Home') next=0; else if(event.key==='End') next=tabs.length-1; else return; event.preventDefault(); activateDetailTab(tabs[next].dataset.detailTab,true); });
});
function renderState(state) {
const layer = $('#stateLayer'); const views = $$('.view');
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>可发现视频设备、手动添加非视频设备,或下载 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', drawingComplete = false, serverVersion = 7, clickTimer;
const draftKey = serial => `yovision-sense-zone-draft:${serial}`;
function safeSession(action) { try { return action(sessionStorage); } catch { return undefined; } }
function persistDraft() {
if (!currentDevice || currentDevice.modality !== 'video') return;
const saved=safeSession(store=>{ store.setItem(draftKey(currentDevice.serial),JSON.stringify({points,activeTool,drawingComplete,baseVersion:serverVersion})); return true; });
$('#autosaveStatus').textContent = saved ? `草稿已自动保存 · ${new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}` : '浏览器禁止会话存储;草稿仅在当前页面内保留。';
}
function clearStoredDraft() { if(currentDevice) safeSession(store=>store.removeItem(draftKey(currentDevice.serial))); }
function restoreDraft(serial) {
points.length=0; activeTool='polygon'; drawingComplete=false; serverVersion=7;
const raw=safeSession(store=>store.getItem(draftKey(serial)));
if(raw) { try { const draft=JSON.parse(raw); points.push(...(draft.points||[])); activeTool=draft.activeTool||'polygon'; drawingComplete=Boolean(draft.drawingComplete); serverVersion=Number(draft.baseVersion)||7; $('#autosaveStatus').textContent='已恢复此设备在当前浏览器会话中的未保存草稿。'; } catch { clearStoredDraft(); } }
$$('.tool-btn[data-tool]').forEach(button=>{ const on=button.dataset.tool===activeTool; button.classList.toggle('active',on); button.setAttribute('aria-pressed',String(on)); });
drawDraft({persist:false});
}
function drawDraft({persist=true} = {}) {
const coords = points.map(p => `${p.x},${p.y}`).join(' ');
const shape = activeTool === 'line' && points.length > 1 ? `<line x1="${points[0].x}" y1="${points[0].y}" x2="${points[1].x}" y2="${points[1].y}" stroke="#fbbf24" stroke-width="5" marker-end="url(#draftArrow)"/>` : drawingComplete && points.length>2 ? `<polygon points="${coords}" fill="rgba(167,139,250,.18)" stroke="#a78bfa" stroke-width="4"/>` : `<polyline points="${coords}" fill="none" stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>`;
draftLayer.innerHTML = shape + points.map((p,i) => `<circle cx="${p.x}" cy="${p.y}" r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${i+1}</title></circle>`).join('');
$('#pointStatus').textContent = `当前草稿:${points.length} 个点${drawingComplete?' · 已完成绘制':' · 绘制中'}`;
$('#pointList').innerHTML = points.length ? points.map((p,i) => `<li class="point-item"><span class="point-index">点 ${i+1}</span><input class="field" type="number" min="0" max="100" step="0.1" value="${(p.x/9.6).toFixed(1)}" data-point-x="${i}" aria-label="点 ${i+1} 横坐标百分比"><input class="field" type="number" min="0" max="100" step="0.1" value="${(p.y/5.4).toFixed(1)}" data-point-y="${i}" aria-label="点 ${i+1} 纵坐标百分比"><button class="btn" data-delete-point="${i}" aria-label="删除点 ${i+1}">删除</button></li>`).join('') : '<li class="muted">尚未添加草稿点</li>';
$('#draftBadge').className = points.length ? 'badge warning' : 'badge info';
$('#draftBadge').textContent = points.length ? `区域有未保存修改 · 基于 v${serverVersion}` : `区域 v${serverVersion}`;
$('#reverseLine').hidden = activeTool !== 'line';
if(persist) persistDraft();
}
function addPoint(x,y) { if (activeTool === 'pointer') return; if (activeTool === 'line' && points.length >= 2) { notify('警戒线已有起点和终点;可反转方向、编辑坐标或清空重画。'); return; } drawingComplete=false; points.push({x:Math.max(0,Math.min(960,x)),y:Math.max(0,Math.min(540,y))}); drawDraft(); }
function finishDrawing() { const minimum=activeTool==='line'?2:3; if(activeTool==='pointer'){ notify('请先选择多边形或警戒线工具。'); return; } if(points.length<minimum){ notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } drawingComplete=true; drawDraft(); notify(`${activeTool==='line'?'方向警戒线':'多边形'}已完成绘制;保存后才会生成新版本。`); }
zoneCanvas.addEventListener('click', e => { clearTimeout(clickTimer); clickTimer=setTimeout(()=>{ const r=zoneCanvas.getBoundingClientRect(); addPoint((e.clientX-r.left)/r.width*960,(e.clientY-r.top)/r.height*540); },240); });
zoneCanvas.addEventListener('dblclick', e => { e.preventDefault(); clearTimeout(clickTimer); finishDrawing(); });
$$('.tool-btn[data-tool]').forEach(btn => btn.addEventListener('click', () => { if(btn.dataset.tool===activeTool) return; if(points.length && !confirm('切换绘制工具会放弃当前图形,是否继续?')) return; activeTool=btn.dataset.tool; points.length=0; drawingComplete=false; $$('.tool-btn[data-tool]').forEach(b=>{ const on=b===btn; b.classList.toggle('active',on); b.setAttribute('aria-pressed',String(on)); }); drawDraft(); $('#editorHint').textContent=activeTool==='line'?'警戒线:依次添加起点与终点,箭头显示方向;可使用“反转方向”。':activeTool==='pointer'?'选择:查看已有区域;切换绘制工具可新建草稿。':'多边形:点击画面添加顶点,再选择“完成绘制”;也可编辑右侧坐标。'; }));
$('#undoPoint').addEventListener('click', () => { points.pop(); drawingComplete=false; drawDraft(); });
$('#finishDrawing').addEventListener('click',finishDrawing);
$('#reverseLine').addEventListener('click',()=>{ if(points.length!==2){ notify('添加起点和终点后才能反转方向。'); return; } points.reverse(); drawDraft(); notify('警戒线方向已反转。'); });
$('#clearPoints').addEventListener('click', () => { if (points.length && confirm('清空当前未保存的绘制草稿?')) { points.length=0; drawDraft(); } });
$('#addCoordinate').addEventListener('click', () => { const x=Number($('#coordX').value), y=Number($('#coordY').value); if (!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>100||y<0||y>100) { notify('坐标必须在 0–100% 之间。'); return; } addPoint(x*9.6,y*5.4); });
$('#pointList').addEventListener('click',event=>{ const button=event.target.closest('[data-delete-point]'); if(!button)return; points.splice(Number(button.dataset.deletePoint),1); drawingComplete=false; drawDraft(); });
$('#pointList').addEventListener('change',event=>{ const xIndex=event.target.dataset.pointX, yIndex=event.target.dataset.pointY; if(xIndex===undefined&&yIndex===undefined)return; const index=Number(xIndex??yIndex), value=Number(event.target.value); if(!Number.isFinite(value)||value<0||value>100){ notify('坐标必须在 0–100% 之间。'); drawDraft({persist:false}); return; } if(xIndex!==undefined) points[index].x=value*9.6; else points[index].y=value*5.4; drawingComplete=false; drawDraft(); });
$('#discardDraft').addEventListener('click', () => { if (!points.length || confirm('放弃未保存的区域修改?')) { points.length=0; drawingComplete=false; clearStoredDraft(); drawDraft({persist:false}); $('#autosaveStatus').textContent='未保存草稿已清除;服务器上的区域版本未改变。'; notify('已放弃本次修改,已保存区域和 Bell 规则均未改变。'); } });
$('#simulateConflict').addEventListener('click',()=>{ serverVersion+=1; $('#autosaveStatus').className='notice warning'; $('#autosaveStatus').textContent=`服务器区域已更新为 v${serverVersion};当前草稿基线较旧,保存将被阻止。`; notify('已模拟另一位用户保存了新版区域。'); });
$('#saveDraft').addEventListener('click', () => { if ((activeTool==='polygon'&&points.length<3)||(activeTool==='line'&&points.length<2)) { notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } if(!drawingComplete){ notify('请先选择“完成绘制”,确认图形边界或警戒线方向。'); return; } const savedBase=JSON.parse(safeSession(store=>store.getItem(draftKey(currentDevice.serial)))||'{}').baseVersion; if(savedBase && savedBase!==serverVersion){ $('#autosaveStatus').className='notice warning'; $('#autosaveStatus').textContent=`保存被阻止:服务器为 v${serverVersion},草稿基于 v${savedBase}。请记录坐标后重新载入最新版本。`; notify('检测到版本冲突,未覆盖其他用户的修改。'); return; } serverVersion+=1; points.length=0; drawingComplete=false; clearStoredDraft(); drawDraft({persist:false}); $('#draftBadge').className='badge success'; $('#draftBadge').textContent=`区域 v${serverVersion} 已保存`; $('#autosaveStatus').className='notice'; $('#autosaveStatus').textContent='保存成功;当前没有未保存草稿。'; notify(`检测区域 v${serverVersion} 已保存;关联的 Bell 规则不会被自动发布。`); });
$('#simulateCalibration').addEventListener('click', () => { $('#calibrationBadge').className='badge warning'; $('#calibrationBadge').textContent='区域待校准'; notify('画面分辨率或旋转发生变化,现有区域已标记为待校准。'); });
const deviceDialog = $('#deviceDialog');
function updatePrivacyAdmission() { const video=$('#deviceModality').value==='video'; const option=$('#deviceArea').selectedOptions[0]; const blocked=video&&option.dataset.capturePolicy==='non_imaging_only'; $('#privacyAdmission').className=`notice${blocked?' warning':''}`; $('#privacyAdmission').textContent=blocked?'该区域仅允许非成像设备:视频设备的新增或启用将被拒绝;已有关系不会被伪造为已停用。':video?'当前区域允许图像采集;保存时仍会再次校验区域策略。':'当前设备不采集图像,可进入“仅非成像设备”区域。'; $('#saveDevice').disabled=blocked; return !blocked; }
function updateDeviceForm(){ const radar=$('#deviceModality').value==='radar'; $$('.video-field').forEach(field=>field.hidden=radar); $$('.radar-field').forEach(field=>field.hidden=!radar); $('#deviceProtocol').value=radar?'mqtt':'onvif'; $('#deviceName').value=radar?'操场西侧雷达':'北围墙 04'; $('#deviceSerial').value=radar?'RAD-DEMO-002':'CAM-DEMO-004'; $('#testResult').textContent=radar?'保存前验证遥测主题、声明能力与设备时间。':'保存前验证 Profiles、StreamUri、声明能力与设备时间。'; updatePrivacyAdmission(); }
$('#openDeviceDialog').addEventListener('click', () => { updateDeviceForm(); deviceDialog.showModal(); });
$('#deviceModality').addEventListener('change',updateDeviceForm); $('#deviceArea').addEventListener('change',updatePrivacyAdmission);
$('#testConnection').addEventListener('click', async e => { const b=e.currentTarget; const radar=$('#deviceModality').value==='radar'; b.disabled=true; b.textContent='测试中…'; $('#testResult').textContent=radar?'正在订阅测试遥测并读取能力声明':'正在读取 Profiles、StreamUri 与设备时间'; await new Promise(r=>setTimeout(r,700)); b.disabled=false; b.textContent='重新测试'; $('#testResult').innerHTML=radar?'<span style="color:var(--success)">连接成功:距离、速度、方位角 · 1 Hz</span>':'<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>'; });
$('#saveDevice').addEventListener('click', () => { if(!updatePrivacyAdmission()) { notify('隐私准入失败:此区域禁止新增图像采集设备。'); return; } const type=$('#deviceModality').value==='radar'?'毫米波雷达':'视频设备'; deviceDialog.close(); notify(`${type}已保存为“待激活”;能力与区域策略会在启用时再次校验。`); });
const operationDialog=$('#operationDialog');
const operationCopy={
'pause-inference':'影响:停止新推理;媒体连接保持在线。执行后将显示期望态与实际态的收敛过程。',
'disable-ingest':'影响:实时监控与新推理都将停止;历史事件和区域版本保留。',
'kick-session':'影响:当前连接立即断开;由于期望态仍为启用,适配器可能自动重连。'
};
function openOperationDialog(serial=currentDevice.serial){ const device=deviceData.find(item=>item.serial===serial)||currentDevice; currentDevice=device; $('#operationDeviceName').textContent=device.name; $('#operationDeviceSerial').textContent=device.serial; $('#operationProgress').textContent='尚未执行。重复提交不会产生重复副作用,但每次请求都会记录审计。'; operationDialog.showModal(); }
$('#openOperationDialog').addEventListener('click',()=>openOperationDialog());
$$('input[name="deviceOperation"]').forEach(input=>input.addEventListener('change',()=>$('#operationImpact').textContent=operationCopy[input.value]));
$('#confirmOperation').addEventListener('click',async event=>{ const button=event.currentTarget; const action=$('input[name="deviceOperation"]:checked').value; const expected=action==='disable-ingest'?'停用':action==='pause-inference'?'推理暂停':'启用'; button.disabled=true; $('#operationProgress').innerHTML=`期望态:<strong>${expected}</strong> · 实际态:执行中…`; await new Promise(resolve=>setTimeout(resolve,800)); const actual=action==='disable-ingest'?'已停止':action==='pause-inference'?'推理已暂停':'会话已断开,等待自动重连'; $('#operationProgress').innerHTML=`期望态:<strong>${expected}</strong> · 实际态:<strong>${actual}</strong> · 已记录本次审计`; button.disabled=false; notify(`${currentDevice.name}:${actual}。`); });
$('#fakeUpload').addEventListener('click', () => notify('演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。'));
renderDevices();
drawDraft({persist:false});
const query = new URLSearchParams(location.search);
if (query.get('view') === 'device-detail' || query.has('camera')) {
openDeviceDetail(query.get('camera') || 'CAM-S2-001');
if (query.get('return_to') === 'bell-rule') $('#ruleReturnContext').hidden = false;
}
</script>
</body>
</html>