690 lines
77 KiB
HTML
690 lines
77 KiB
HTML
<!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&camera=CAM-S2-001&zone=ZONE-007&return_to=sense-device">返回规则草稿</a></div>
|
||
<div class="breadcrumb"><button id="backToDevices">设备</button><span>/</span><span id="deviceBreadcrumb">北围墙 01</span></div>
|
||
<div class="page-head"><div><h1 id="deviceDetailName">北围墙 01</h1><p><span id="deviceDetailSerial" class="mono">CAM-S2-001</span> · <span id="deviceDetailDescription">视频接入、画面与空间配置。</span></p></div><div class="page-actions"><span class="badge success" id="calibrationBadge">区域已校准</span><button class="btn" id="simulateCalibration">模拟画面参数变化</button><button class="btn btn-danger" id="openOperationDialog">停止或隔离…</button></div></div>
|
||
<div class="detail-tabs" role="tablist" aria-label="设备详情">
|
||
<button id="tab-info" role="tab" data-detail-tab="info" aria-controls="panel-info" aria-selected="false" tabindex="-1">基本信息</button>
|
||
<button id="tab-zones" class="active" role="tab" data-detail-tab="zones" aria-controls="panel-zones" aria-selected="true" tabindex="0">画面与检测区域</button>
|
||
<button id="tab-health" role="tab" data-detail-tab="health" aria-controls="panel-health" aria-selected="false" tabindex="-1">连接与健康</button>
|
||
<button id="tab-history" role="tab" data-detail-tab="history" aria-controls="panel-history" aria-selected="false" tabindex="-1">操作记录</button>
|
||
</div>
|
||
|
||
<section 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&camera=CAM-S2-001&zone=ZONE-007&return_to=sense-device">在 Bell 配置规则</a></div>
|
||
<div class="settings-actions"><button class="btn" id="simulateConflict">模拟版本冲突</button><button class="btn" id="discardDraft">放弃修改</button><button class="btn btn-primary" id="saveDraft">保存区域版本</button></div>
|
||
</div></aside>
|
||
</div>
|
||
</section>
|
||
|
||
<section 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>
|