Files
yovision/docs/design/sense/index.html
T
ila 2715e557a9
Harness governance / validate (push) Has been cancelled
docs(design): update Sense workflow
2026-08-04 09:04:50 +08:00

422 lines
39 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: #71849c;
--line: #27384d;
--primary: #38bdf8;
--primary-strong: #0ea5e9;
--primary-soft: rgba(56, 189, 248, .14);
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--violet: #a78bfa;
--shadow: 0 16px 40px rgba(0, 0, 0, .22);
--radius: 14px;
--sidebar: 224px;
--banner: 28px;
}
html[data-theme="light"] {
--bg: #eef4f8;
--surface: #ffffff;
--surface-2: #f7fafc;
--surface-3: #eaf1f6;
--text: #112033;
--muted: #50647b;
--subtle: #718096;
--line: #cfdae5;
--primary-soft: rgba(2, 132, 199, .11);
--shadow: 0 14px 34px rgba(38, 64, 92, .12);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body {
margin: 0;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font: 14px/1.5 Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
button, input, select, textarea { font: inherit; }
button, select, input[type="checkbox"], input[type="radio"] { cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible {
outline: 3px solid rgba(56, 189, 248, .45);
outline-offset: 2px;
}
.prototype-banner {
position: fixed; inset: 0 0 auto 0; z-index: 1000;
height: var(--banner); display: flex; align-items: center; justify-content: center;
background: #f6c344; color: #211900; font-size: 12px; font-weight: 800; letter-spacing: .07em;
}
.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
.app { min-height: 100dvh; padding-top: var(--banner); display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); }
.sidebar {
position: sticky; top: var(--banner); height: calc(100dvh - var(--banner));
border-right: 1px solid var(--line); background: var(--surface); padding: 18px 12px;
display: flex; flex-direction: column; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 2px 10px 20px; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; background: linear-gradient(145deg, #38bdf8, #6366f1); color: white; font-weight: 900; box-shadow: 0 8px 20px rgba(14, 165, 233, .22); }
.brand strong { display: block; font-size: 16px; letter-spacing: .01em; }
.brand small { color: var(--muted); }
.nav-label { margin: 8px 10px 6px; color: var(--subtle); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; }
.nav { display: grid; gap: 4px; }
.nav button {
min-height: 44px; padding: 0 12px; border: 0; border-radius: 10px; background: transparent; color: var(--muted);
display: flex; align-items: center; gap: 10px; text-align: left; transition: background .2s ease, color .2s ease;
}
.nav button:hover { color: var(--text); background: var(--surface-2); }
.nav button[aria-current="page"] { color: var(--text); background: var(--primary-soft); box-shadow: inset 3px 0 var(--primary); }
.nav .count { margin-left: auto; min-width: 22px; padding: 1px 6px; border-radius: 999px; background: var(--surface-3); color: var(--text); text-align: center; font-size: 12px; }
.sidebar-foot { margin-top: auto; padding: 12px 10px 2px; border-top: 1px solid var(--line); color: var(--muted); }
.online-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.main { min-width: 0; }
.topbar {
position: sticky; top: var(--banner); z-index: 20; min-height: 64px; padding: 10px 22px;
display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 94%, transparent); backdrop-filter: blur(12px);
}
.site-select { display: flex; align-items: center; gap: 8px; min-width: 250px; }
.site-select strong { font-size: 15px; }
.site-select span { color: var(--muted); font-size: 12px; }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.select, .field, .textarea {
width: 100%; min-height: 40px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); padding: 8px 11px;
}
.textarea { min-height: 74px; resize: vertical; }
.top-actions .select { width: auto; min-width: 126px; }
.btn {
min-height: 40px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 13px; background: var(--surface-2); color: var(--text);
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: 42px; padding: 0; }
.content { padding: 22px; }
.view { display: none; animation: reveal .22s ease-out; }
.view.active { display: block; }
@keyframes reveal { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: 24px; line-height: 1.25; }
.page-head p { margin: 0; color: var(--muted); }
.page-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.grid { display: grid; gap: 14px; }
.stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
.stat { padding: 16px; min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; }
.stat-top { display: flex; align-items: center; justify-content: space-between; color: var(--muted); }
.stat strong { font-size: 27px; font-variant-numeric: tabular-nums; }
.trend { font-size: 12px; color: var(--success); }
.split { grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); margin-top: 14px; }
.card-head { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--line); }
.card-head h2, .card-head h3 { margin: 0; font-size: 15px; }
.card-body { padding: 16px; }
.health-list { display: grid; gap: 12px; }
.health-row { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; }
.health-row span { color: var(--muted); }
.health-row strong { font-variant-numeric: tabular-nums; }
.meter { grid-column: 1/-1; height: 7px; border-radius: 99px; background: var(--surface-3); overflow: hidden; }
.meter i { display: block; height: 100%; border-radius: inherit; background: var(--primary); }
.badge { display: inline-flex; align-items: center; gap: 5px; min-height: 24px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; white-space: nowrap; }
.badge.success { color: #74efc1; border-color: rgba(52, 211, 153, .35); background: rgba(52, 211, 153, .1); }
.badge.warning { color: #ffdc74; border-color: rgba(251, 191, 36, .35); background: rgba(251, 191, 36, .1); }
.badge.danger { color: #ff9aae; border-color: rgba(251, 113, 133, .4); background: rgba(251, 113, 133, .1); }
.badge.info { color: #8edaff; border-color: rgba(56, 189, 248, .35); background: var(--primary-soft); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.search { position: relative; min-width: 220px; }
.search .icon { position: absolute; left: 11px; top: 11px; color: var(--subtle); pointer-events: none; }
.search input { padding-left: 38px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 960px; }
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: middle; }
th { color: var(--muted); background: var(--surface-2); font-size: 12px; font-weight: 700; }
td { font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
tr:hover td { background: color-mix(in srgb, var(--primary-soft) 45%, transparent); }
.device-name { display: flex; align-items: center; gap: 10px; }
.camera-icon { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; background: var(--surface-3); color: var(--primary); }
.device-name small { display: block; color: var(--muted); }
.camera-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.camera-card { overflow: hidden; }
.camera-stage { aspect-ratio: 16/9; position: relative; background: #020914; overflow: hidden; }
.camera-stage::before { content: ""; position: absolute; inset: 0; background: linear-gradient(160deg, transparent 45%, rgba(56,189,248,.08)), repeating-linear-gradient(0deg, rgba(255,255,255,.025) 0 1px, transparent 1px 4px); }
.scene { position: absolute; inset: 0; width: 100%; height: 100%; }
.camera-meta { padding: 12px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.camera-meta small { color: var(--muted); }
.breadcrumb { margin-bottom: 10px; display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.breadcrumb button { min-height: 32px; padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--primary); font-weight: 700; }
.breadcrumb button:hover { background: var(--primary-soft); }
.detail-tabs { margin-bottom: 12px; padding: 5px; display: flex; gap: 5px; overflow-x: auto; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }
.detail-tabs button { min-height: 40px; padding: 7px 13px; border: 0; border-radius: 8px; background: transparent; color: var(--muted); white-space: nowrap; }
.detail-tabs button.active { background: var(--primary-soft); color: var(--text); box-shadow: inset 0 -2px var(--primary); }
.detail-panel[hidden] { display: none; }
.context-strip { margin-bottom: 12px; padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid rgba(167,139,250,.38); border-radius: 10px; background: rgba(167,139,250,.1); }
.context-strip[hidden] { display: none; }
.context-strip p { margin: 0; color: var(--muted); }
.zone-layout { display: grid; grid-template-columns: minmax(420px, 1fr) 320px; gap: 12px; min-height: calc(100dvh - 260px); }
.zone-camera-list, .zone-settings, .zone-workspace { min-width: 0; }
.compact-list { display: grid; gap: 6px; padding: 10px; }
.compact-list button { width: 100%; min-height: 54px; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--text); display: flex; align-items: center; gap: 10px; padding: 8px; text-align: left; }
.compact-list button:hover, .compact-list button.selected { background: var(--primary-soft); border-color: rgba(56,189,248,.32); }
.compact-list small { display: block; color: var(--muted); }
.mini-thumb { width: 54px; height: 34px; border-radius: 6px; background: linear-gradient(145deg, #24364c, #0a101a); position: relative; overflow: hidden; }
.mini-thumb::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: 8px; height: 1px; background: var(--primary); transform: rotate(-12deg); }
.editor-tools { padding: 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid var(--line); overflow-x: auto; }
.tool-btn { min-width: 42px; min-height: 40px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--muted); display: inline-flex; align-items: center; justify-content: center; gap: 6px; white-space: nowrap; }
.tool-btn.active { background: var(--primary-soft); border-color: var(--primary); color: var(--text); }
.tool-divider { width: 1px; align-self: stretch; background: var(--line); margin: 0 3px; }
.editor-stage { position: relative; aspect-ratio: 16/9; min-height: 340px; background: #030a12; overflow: hidden; }
.editor-stage svg { width: 100%; height: 100%; display: block; touch-action: none; }
.editor-hint { position: absolute; left: 12px; bottom: 12px; padding: 7px 10px; border-radius: 8px; background: rgba(3, 10, 18, .78); color: #dceaff; font-size: 12px; pointer-events: none; }
.editor-status { display: flex; justify-content: space-between; gap: 10px; padding: 10px 12px; color: var(--muted); border-top: 1px solid var(--line); font-size: 12px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-field { display: grid; gap: 6px; }
.form-field.full { grid-column: 1/-1; }
.form-field label { color: var(--muted); font-size: 12px; font-weight: 700; }
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 42px; }
.switch { position: relative; width: 42px; height: 24px; }
.switch input { position: absolute; opacity: 0; }
.switch span { position: absolute; inset: 0; border-radius: 99px; background: var(--surface-3); border: 1px solid var(--line); transition: .2s ease; }
.switch span::after { content: ""; position: absolute; width: 18px; height: 18px; left: 2px; top: 2px; border-radius: 50%; background: var(--muted); transition: transform .2s ease, background .2s ease; }
.switch input:checked + span { background: var(--primary-strong); border-color: var(--primary-strong); }
.switch input:checked + span::after { transform: translateX(18px); background: white; }
.coordinate-box { margin-top: 12px; padding: 12px; border: 1px dashed var(--line); border-radius: 10px; background: var(--surface-2); }
.coordinate-box h3 { margin: 0 0 4px; font-size: 13px; }
.coordinate-box p { margin: 0 0 10px; color: var(--muted); font-size: 12px; }
.coordinate-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 6px; }
.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-h…3676 tokens truncated…>
<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="muted" aria-live="polite"><li>尚未添加草稿点</li></ol></div>
<div class="linked-rules"><h3>关联的告警规则</h3><p>规则由 Bell 管理;Sense 只提供摄像头快照和版本化空间坐标。</p><ul><li>非授权人员进入 · 正式生效</li><li>区域内聚集 · 试运行</li></ul><a class="btn" id="configureRules" href="../bell/index.html?view=rules&amp;camera=CAM-S2-001&amp;zone=ZONE-007">在 Bell 配置规则</a></div>
<div class="settings-actions"><button class="btn" id="discardDraft">放弃修改</button><button class="btn btn-primary" id="saveDraft">保存区域版本</button></div>
</div></aside>
</div>
</section>
<section class="detail-panel" data-detail-panel="health" hidden><div class="grid system-grid"><article class="card system-tile"><h3>媒体流</h3><dl><dt>实际态</dt><dd><span class="badge success">在线</span></dd><dt>分片</dt><dd>media-01</dd><dt>重连</dt><dd>0</dd></dl></article><article class="card system-tile"><h3>设备时间</h3><dl><dt>漂移</dt><dd>+0.2s</dd><dt>最近校时</dt><dd>5m 前</dd></dl></article><article class="card system-tile"><h3>区域校准</h3><dl><dt>坐标版本</dt><dd>v7</dd><dt>画面参数</dt><dd>匹配</dd></dl></article></div></section>
<section class="detail-panel" data-detail-panel="history" hidden><article class="card"><div class="card-head"><h2>操作记录</h2><span class="badge info">只读</span></div><div class="table-wrap"><table><thead><tr><th>时间</th><th>操作</th><th>对象</th><th>执行人</th><th>结果</th></tr></thead><tbody><tr><td>22:52</td><td>保存检测区域</td><td>ZONE-007 · v7</td><td>实施工程师</td><td><span class="badge success">成功</span></td></tr><tr><td>22:41</td><td>校验子码流参数</td><td>704 × 576</td><td>系统</td><td><span class="badge success">匹配</span></td></tr></tbody></table></div></article></section>
</section>
<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">设备</button><button data-view="onboarding">接入</button><button data-view="system">系统</button>
</nav>
<dialog id="cameraDialog" aria-labelledby="cameraDialogTitle">
<form method="dialog">
<div class="dialog-head"><h2 id="cameraDialogTitle">添加摄像头</h2><button class="btn btn-quiet icon-btn" value="cancel" aria-label="关闭对话框">×</button></div>
<div class="dialog-body"><div class="form-grid">
<div class="form-field"><label for="cameraName">名称</label><input class="field" id="cameraName" required value="北围墙 04"></div>
<div class="form-field"><label for="cameraProtocol">协议</label><select class="select" id="cameraProtocol"><option>ONVIF</option><option>RTSP</option></select></div>
<div class="form-field full"><label for="cameraEndpoint">设备地址</label><input class="field" id="cameraEndpoint" value="192.0.2.24:80" aria-describedby="endpointHelp"><small id="endpointHelp" class="muted">原型使用保留地址;保存后列表仅显示脱敏端点。</small></div>
<div class="form-field"><label for="cameraUser">用户名</label><input class="field" id="cameraUser" value="operator"></div>
<div class="form-field"><label for="cameraPassword">密码</label><input class="field" id="cameraPassword" type="password" value="prototype-only"></div>
<div class="form-field"><label for="cameraArea">区域</label><select class="select" id="cameraArea"><option>北围墙</option><option>实验楼</option><option>宿舍区</option></select></div>
<div class="form-field"><label for="cameraSerial">序列号</label><input class="field" id="cameraSerial" value="CAM-DEMO-004"></div>
</div><p><button type="button" class="btn" id="testConnection">测试连接</button> <span id="testResult" class="muted" aria-live="polite">保存前先验证 Profiles、StreamUri 与校时能力。</span></p></div>
<div class="dialog-foot"><button class="btn" value="cancel">取消</button><button class="btn btn-primary" id="saveCamera" value="default">保存为待激活</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const toast = $('#toast');
let toastTimer;
function notify(message) { toast.textContent = message; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('show'), 3600); }
const deviceData = [
['北围墙 01','CAM-S2-001','启用','在线','刚刚','+0.2s','media-01','success','2 个区域','success'],
['北围墙 03','CAM-S2-003','启用','重连中','2 分钟前','—','media-01','warning','待校准','warning'],
['实验楼入口 02','CAM-S2-006','启用','在线','12 秒前','+3.8s','media-02','warning','1 个区域','success'],
['宿舍东通道','CAM-S2-011','启用','在线','4 秒前','+0.1s','media-02','success','3 个区域','success'],
['配电房门口','CAM-S2-015','停用','已停止','1 天前','—','未绑定','info','未配置','info']
];
$('#deviceRows').innerHTML = deviceData.map(d => `<tr><td><input type="checkbox" aria-label="选择 ${d[0]}"></td><td><div class="device-name"><span class="camera-icon"><svg class="icon"><use href="#i-camera"/></svg></span><span><strong>${d[0]}</strong><small>${d[1]}</small></span></div></td><td>${d[2]}</td><td><span class="badge ${d[7]}">${d[3]}</span></td><td>${d[4]}</td><td>${d[5]}</td><td>${d[6]}</td><td><span class="badge ${d[9]}">${d[8]}</span></td><td><button class="btn" data-open-camera="${d[1]}">查看详情</button></td></tr>`).join('');
const 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 current = b.dataset.view === navName; b.toggleAttribute('aria-current', current); b.classList.toggle('active', current); });
$('#stateSelect').value = 'normal'; renderState('normal');
$('#main-content').focus({preventScroll:true});
}
$$('[data-view]').forEach(b => b.addEventListener('click', () => switchView(b.dataset.view)));
function openDeviceDetail(serial = 'CAM-S2-001') {
const device = deviceData.find(d => d[1] === serial) || deviceData[0];
$('#deviceDetailName').textContent = device[0]; $('#deviceBreadcrumb').textContent = device[0]; $('#deviceDetailSerial').textContent = device[1];
const bellHref = `../bell/index.html?view=rules&camera=${encodeURIComponent(device[1])}&zone=ZONE-007`;
$('#configureRules').href = bellHref; $('#returnToBell').href = bellHref;
switchView('device-detail');
}
$$('[data-open-camera]').forEach(b => b.addEventListener('click', () => openDeviceDetail(b.dataset.openCamera)));
$('#backToDevices').addEventListener('click', () => switchView('devices'));
$$('.detail-tabs button').forEach(button => button.addEventListener('click', () => {
const name = button.dataset.detailTab;
$$('.detail-tabs button').forEach(b => { const on = b === button; b.classList.toggle('active', on); b.setAttribute('aria-selected', on); });
$$('[data-detail-panel]').forEach(panel => panel.hidden = panel.dataset.detailPanel !== name);
}));
function renderState(state) {
const layer = $('#stateLayer'); const views = $$('.view');
layer.classList.toggle('show', state !== 'normal'); views.forEach(v => v.style.display = state === 'normal' ? '' : 'none');
if (state === 'normal') return;
const configs = {
loading: `<div><div class="skeleton" aria-label="正在加载"><i></i><i></i><i></i><i></i></div><h2>正在同步边缘状态</h2><p>超过 300ms 时显示进度;已有视频流不因控制面加载而中断。</p></div>`,
empty: `<div><svg class="icon"><use href="#i-camera"/></svg><h2>这个站点还没有摄像头</h2><p>先扫描 ONVIF 设备,或下载 CSV 模板进行批量导入。</p><button class="btn btn-primary" onclick="switchView('onboarding')">开始接入</button></div>`,
error: `<div><svg class="icon" style="color:var(--danger)"><use href="#i-alert"/></svg><h2>无法读取最新状态</h2><p>边缘节点暂时不可达。当前显示可能已过期,系统不会把未知状态伪装为在线。</p><button class="btn" onclick="document.querySelector('#stateSelect').value='normal';renderState('normal')">重试</button></div>`
}; layer.innerHTML = configs[state];
}
$('#stateSelect').addEventListener('change', e => renderState(e.target.value));
$('#themeToggle').addEventListener('click', () => { const html = document.documentElement; html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark'; notify(`已切换为${html.dataset.theme === 'dark' ? '深色' : '浅色'}主题`); });
const zoneCanvas = $('#zoneCanvas'), draftLayer = $('#draftLayer'), points = [];
let activeTool = 'polygon';
function drawDraft() {
const coords = points.map(p => `${p.x},${p.y}`).join(' ');
const shape = activeTool === 'line' && points.length > 1 ? `<line x1="${points[0].x}" y1="${points[0].y}" x2="${points[1].x}" y2="${points[1].y}" stroke="#fbbf24" stroke-width="5"/>` : `<polyline points="${coords}" fill="${points.length > 2 ? 'rgba(167,139,250,.18)' : 'none'}" stroke="#a78bfa" stroke-width="4" stroke-dasharray="8 6"/>`;
draftLayer.innerHTML = shape + points.map((p,i) => `<circle cx="${p.x}" cy="${p.y}" r="8" fill="#08111f" stroke="#c4b5fd" stroke-width="4"><title>点 ${i+1}</title></circle>`).join('');
$('#pointStatus').textContent = `当前草稿:${points.length} 个点`;
$('#pointList').innerHTML = points.length ? points.map((p,i) => `<li>点 ${i+1}:${(p.x/9.6).toFixed(1)}%, ${(p.y/5.4).toFixed(1)}%</li>`).join('') : '<li>尚未添加草稿点</li>';
$('#draftBadge').className = points.length ? 'badge warning' : 'badge info';
$('#draftBadge').textContent = points.length ? '区域有未保存修改' : '区域 v7';
}
function addPoint(x,y) { if (activeTool === 'pointer') return; if (activeTool === 'line' && points.length >= 2) points.length = 0; points.push({x:Math.max(0,Math.min(960,x)),y:Math.max(0,Math.min(540,y))}); drawDraft(); }
zoneCanvas.addEventListener('click', e => { const r = zoneCanvas.getBoundingClientRect(); addPoint((e.clientX-r.left)/r.width*960,(e.clientY-r.top)/r.height*540); });
zoneCanvas.addEventListener('dblclick', e => { e.preventDefault(); if (activeTool === 'polygon' && points.length >= 3) notify(`多边形已闭合,共 ${points.length} 个顶点;请保存草稿。`); });
$$('.tool-btn[data-tool]').forEach(btn => btn.addEventListener('click', () => { activeTool = btn.dataset.tool; $$('.tool-btn[data-tool]').forEach(b => { const on = b === btn; b.classList.toggle('active', on); b.setAttribute('aria-pressed', on); }); points.length = 0; drawDraft(); $('#editorHint').textContent = activeTool === 'line' ? '警戒线:依次添加起点与终点;箭头方向由起点指向终点。' : activeTool === 'pointer' ? '选择:查看已有区域;切换绘制工具可新建草稿。' : '多边形:点击画面添加顶点,双击闭合;也可在右侧输入坐标。'; }));
$('#undoPoint').addEventListener('click', () => { points.pop(); drawDraft(); });
$('#clearPoints').addEventListener('click', () => { if (points.length && confirm('清空当前未保存的绘制草稿?')) { points.length=0; drawDraft(); } });
$('#addCoordinate').addEventListener('click', () => { const x=Number($('#coordX').value), y=Number($('#coordY').value); if (!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>100||y<0||y>100) { notify('坐标必须在 0–100% 之间。'); return; } addPoint(x*9.6,y*5.4); });
$('#discardDraft').addEventListener('click', () => { if (!points.length || confirm('放弃未保存的区域修改?')) { points.length=0; drawDraft(); notify('已放弃本次修改,已保存区域和 Bell 规则均未改变。'); } });
$('#saveDraft').addEventListener('click', () => { if ((activeTool==='polygon'&&points.length<3)||(activeTool==='line'&&points.length<2)) { notify(activeTool==='line'?'警戒线至少需要 2 个点。':'多边形至少需要 3 个点。'); return; } points.length=0; drawDraft(); $('#draftBadge').className='badge success'; $('#draftBadge').textContent='区域 v8 已保存'; notify('检测区域 v8 已保存;关联的 Bell 规则不会被自动发布。'); });
$('#simulateCalibration').addEventListener('click', () => { $('#calibrationBadge').className='badge warning'; $('#calibrationBadge').textContent='区域待校准'; notify('画面分辨率或旋转发生变化,现有区域已标记为待校准。'); });
const dialog = $('#cameraDialog');
$('#openCameraDialog').addEventListener('click', () => dialog.showModal());
$('#testConnection').addEventListener('click', async e => { const b=e.currentTarget; b.disabled=true; b.textContent='测试中…'; $('#testResult').textContent='正在读取 Profiles 与设备时间'; await new Promise(r=>setTimeout(r,700)); b.disabled=false; b.textContent='重新测试'; $('#testResult').innerHTML='<span style="color:var(--success)">连接成功:2 个 Profile,时间漂移 +0.4s</span>'; });
$('#saveCamera').addEventListener('click', () => notify('摄像头已保存为“待激活”,凭据不会显示在列表中。'));
$('#fakeUpload').addEventListener('click', () => notify('演示:已选择 cameras-demo.csv,检测到 1 个重复序列号。'));
drawDraft();
const query = new URLSearchParams(location.search);
if (query.get('view') === 'device-detail' || query.has('camera')) {
openDeviceDetail(query.get('camera') || 'CAM-S2-001');
if (query.get('return') === 'bell-rule') $('#ruleReturnContext').hidden = false;
}
</script>
</body>
</html>