diff --git a/web/src/main.js b/web/src/main.js index 2ac5188..26cf818 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -33,51 +33,74 @@ const app = document.querySelector("#app"); app.innerHTML = `
-
+
-
CD
-
+
CG
+
+
COLD DISPLAY GUARD
冷藏展示柜管理
-
标定、配置、事件数据
+
区域标定 / 运行监控 / 合规事件
+ + +
+
+ + +
+ +
-
- - -
-
-
- - - +
+
+

CALIBRATION

+

冷柜区域标定

+
+

抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。

-
-
@@ -139,6 +187,8 @@ const els = { regionSummary: document.querySelector("#regionSummary"), metrics: document.querySelector("#metrics"), eventsTable: document.querySelector("#eventsTable"), + statusPill: document.querySelector("#statusPill"), + activeRegionBadge: document.querySelector("#activeRegionBadge"), }; const ctx = els.canvas.getContext("2d"); @@ -377,9 +427,20 @@ function renderRegionList() { els.regionList.innerHTML = ""; for (const id of allRegions) { const button = document.createElement("button"); + const complete = state.polygons[id].length >= 3; button.type = "button"; - button.textContent = `${id}${state.polygons[id].length >= 3 ? " ✓" : ""}`; - button.className = id === state.activeRegion ? "active" : ""; + button.className = [ + "region-button", + id === state.activeRegion ? "active" : "", + complete ? "complete" : "", + ].filter(Boolean).join(" "); + button.style.setProperty("--region-color", palette[id] || "#ffffff"); + button.innerHTML = ` + + ${escapeHtml(getRegionLabel(id))} + ${escapeHtml(id)} + ${state.polygons[id].length} + `; button.addEventListener("click", () => { state.activeRegion = id; render(); @@ -538,28 +599,45 @@ function renderRegionSummary() { els.regionSummary.innerHTML = allRegions .map((id) => { const count = state.polygons[id].length; - return `
${id}${count >= 3 ? `${count} 点,已标定` : `${count} 点`}
`; + const complete = count >= 3; + return ` +
+ + ${escapeHtml(getRegionLabel(id))} + ${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`} +
+ `; }) .join(""); + els.activeRegionBadge.textContent = state.activeRegion; } function renderMetrics() { const metrics = state.summary?.metrics || {}; + const violationCount = metrics.violation_count ?? 0; + const baselineReady = Boolean(metrics.baseline_ready); const cards = [ - ["事件总数", metrics.event_count ?? 0], - ["违规事件", metrics.violation_count ?? 0], - ["诊断帧数", metrics.diagnostics_count ?? 0], - ["基线状态", metrics.baseline_ready ? "ready" : "learning"], - ["最新报警", metrics.latest_alert_time || "-"], - ["事件文件", metrics.events_path || "-"], + {label: "事件总数", value: metrics.event_count ?? 0, tone: "neutral"}, + {label: "违规事件", value: violationCount, tone: violationCount > 0 ? "danger" : "good"}, + {label: "诊断帧数", value: metrics.diagnostics_count ?? 0, tone: "neutral"}, + {label: "基线状态", value: baselineReady ? "ready" : "learning", tone: baselineReady ? "good" : "warning"}, + {label: "最新报警", value: metrics.latest_alert_time || "-", tone: metrics.latest_alert_time ? "danger" : "neutral"}, + {label: "事件文件", value: metrics.events_path || "-", tone: "path"}, ]; const zoneCounts = metrics.latest_zone_counts || {}; const zoneSummary = Object.keys(zoneCounts).length - ? `
最新区域状态${Object.entries(zoneCounts) + ? `
最新区域状态${Object.entries(zoneCounts) .map(([zoneId, count]) => `${zoneId}:${count}`) .join(" ")}
` : ""; - els.metrics.innerHTML = cards.map(([label, value]) => `
${label}${value}
`).join("") + zoneSummary; + els.metrics.innerHTML = cards + .map((card) => ` +
+ ${escapeHtml(card.label)} + ${escapeHtml(String(card.value))} +
+ `) + .join("") + zoneSummary; } function renderEvents() { @@ -574,15 +652,19 @@ function renderEvents() { ${state.events .slice() .reverse() - .map((event) => ` - + .map((event) => { + const eventName = event.event || ""; + const isViolation = eventName.includes("violation"); + return ` + ${escapeHtml(event.ts || "")} - ${escapeHtml(event.event || "")} + ${escapeHtml(eventName)} ${escapeHtml(event.zone_id || "")} ${escapeHtml(event.batch_id || "")} ${escapeHtml(String(event.dwell_seconds ?? ""))} - `) + `; + }) .join("")} @@ -627,6 +709,23 @@ async function apiJson(path, options = {}) { function setStatus(message) { state.status = message; els.statusText.textContent = message; + const tone = message.includes("失败") || message.includes("错误") + ? "error" + : message.includes("正在") + ? "busy" + : "ready"; + els.statusPill.className = `status-pill ${tone}`; +} + +function getRegionLabel(id) { + if (id === "trash") { + return "垃圾桶"; + } + const match = id.match(/^r(\d)c(\d)$/); + if (!match) { + return id; + } + return `${match[1]}排${match[2]}列`; } function clamp(value) { diff --git a/web/src/styles.css b/web/src/styles.css index 84fc725..be7d74c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1,7 +1,23 @@ :root { - font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #1d2939; - background: #f3f5f7; + color-scheme: light; + --ink: #15202b; + --muted: #637381; + --soft: #8b98a7; + --paper: #f6f8fa; + --panel: #ffffff; + --line: #d7dee5; + --line-strong: #aeb9c4; + --header: #111a22; + --header-2: #17232d; + --cyan: #0087a8; + --green: #0f8f61; + --amber: #b76e00; + --red: #c93232; + --blue: #235f9f; + --shadow: 0 18px 50px rgba(17, 26, 34, 0.12); + font-family: "Avenir Next", "DIN Alternate", "PingFang SC", "Microsoft YaHei", sans-serif; + color: var(--ink); + background: var(--paper); } * { @@ -10,6 +26,10 @@ body { margin: 0; + min-width: 320px; + background: + linear-gradient(180deg, rgba(17, 26, 34, 0.08), rgba(17, 26, 34, 0) 260px), + var(--paper); } button, @@ -18,167 +38,425 @@ input { } button { - border: 1px solid #c8d0d8; + min-height: 38px; + border: 1px solid var(--line-strong); background: #ffffff; - color: #1d2939; + color: var(--ink); border-radius: 6px; - padding: 8px 12px; + padding: 8px 13px; cursor: pointer; + font-weight: 700; + transition: border-color 140ms ease, background 140ms ease, color 140ms ease, transform 140ms ease; } button:hover { - background: #eef3f7; + border-color: #7e8c99; + background: #eef3f6; +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid rgba(0, 135, 168, 0.25); + outline-offset: 2px; +} + +.primary-action { + border-color: var(--cyan); + background: var(--cyan); + color: #ffffff; +} + +.primary-action:hover { + border-color: #006f8b; + background: #006f8b; +} + +.success-action { + border-color: var(--green); + background: var(--green); + color: #ffffff; +} + +.success-action:hover { + border-color: #0a7550; + background: #0a7550; +} + +.secondary-action { + border-color: rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.08); + color: #f4f7f9; +} + +.secondary-action:hover { + border-color: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.16); } input { width: 100%; - border: 1px solid #b6c0ca; + min-height: 40px; + border: 1px solid var(--line-strong); border-radius: 6px; - padding: 9px 10px; + padding: 9px 11px; background: #ffffff; + color: var(--ink); } -label { +input::placeholder { + color: #9aa6b2; +} + +.field { display: grid; - gap: 6px; - color: #475467; + gap: 7px; + color: var(--muted); font-size: 13px; + font-weight: 700; +} + +.field span { + text-transform: none; } .shell { min-height: 100vh; } -.header { - display: flex; +.console-header { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto minmax(320px, 1fr); align-items: center; - justify-content: space-between; - gap: 24px; - padding: 14px 24px; - background: #ffffff; - border-bottom: 1px solid #d8dee5; + gap: 18px; + padding: 14px 20px; + background: var(--header); + color: #ffffff; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 10px 30px rgba(17, 26, 34, 0.24); } .brand { display: flex; align-items: center; + min-width: 0; gap: 12px; } .brand-mark { display: grid; + flex: 0 0 auto; place-items: center; - width: 40px; - height: 40px; + width: 42px; + height: 42px; + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; - background: #155eef; - color: #ffffff; - font-weight: 700; + background: #e6f7f8; + color: #0d4f5d; + font-family: "DIN Alternate", "Avenir Next Condensed", sans-serif; + font-size: 17px; + font-weight: 900; + letter-spacing: 0; +} + +.brand-copy { + min-width: 0; +} + +.brand-kicker, +.view-kicker, +.panel-meta { + color: #6fb9c6; + font-size: 11px; + font-weight: 900; + letter-spacing: 0; } .brand-title { + margin-top: 2px; font-size: 18px; - font-weight: 700; + font-weight: 900; + line-height: 1.15; } .brand-subtitle { margin-top: 2px; - color: #667085; - font-size: 13px; + color: #a9b8c5; + font-size: 12px; } .tabs { + display: inline-flex; + gap: 4px; + padding: 4px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); +} + +.tabs button { + min-height: 34px; + border-color: transparent; + background: transparent; + color: #c5d0da; + white-space: nowrap; +} + +.tabs button:hover { + background: rgba(255, 255, 255, 0.08); + color: #ffffff; +} + +.tabs button.active { + background: #ffffff; + color: #13212c; +} + +.header-actions { + display: flex; + justify-content: flex-end; + align-items: center; + min-width: 0; + gap: 10px; +} + +.status-pill { + display: flex; + align-items: center; + min-width: 0; + max-width: 420px; + gap: 8px; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.07); + color: #dce7ee; + font-size: 13px; +} + +.status-pill span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.status-dot { + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--soft); + box-shadow: 0 0 0 4px rgba(139, 152, 167, 0.18); +} + +.status-pill.ready .status-dot { + background: var(--green); + box-shadow: 0 0 0 4px rgba(15, 143, 97, 0.22); +} + +.status-pill.busy .status-dot { + background: var(--amber); + box-shadow: 0 0 0 4px rgba(183, 110, 0, 0.24); +} + +.status-pill.error .status-dot { + background: var(--red); + box-shadow: 0 0 0 4px rgba(201, 50, 50, 0.24); +} + +.main { + padding: 18px; +} + +.view { + display: grid; + gap: 14px; +} + +.view.hidden, +.hidden { + display: none; +} + +.view-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: 16px; + padding: 4px 2px; +} + +.view-kicker { + margin: 0 0 4px; + color: var(--cyan); +} + +.view-head h1 { + margin: 0; + font-size: 24px; + line-height: 1.1; + letter-spacing: 0; +} + +.view-note { + max-width: 560px; + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.55; + text-align: right; +} + +.command-bar { + display: grid; + grid-template-columns: minmax(320px, 1fr) auto; + gap: 12px; + align-items: end; + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.command-actions { display: flex; gap: 8px; } -.tabs button.active { - background: #155eef; - border-color: #155eef; - color: #ffffff; -} - -.main { - padding: 16px; -} - -.status-line { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; - color: #475467; -} - -.toolbar { +.calibration-layout { display: grid; - grid-template-columns: minmax(360px, 1fr) auto auto; + grid-template-columns: 260px minmax(520px, 1fr) 300px; gap: 12px; - align-items: end; - margin-bottom: 12px; - padding: 12px; - background: #ffffff; - border: 1px solid #d8dee5; - border-radius: 8px; -} - -.calibration-grid { - display: grid; - grid-template-columns: 220px minmax(520px, 1fr) 260px; - gap: 12px; - min-height: calc(100vh - 190px); + min-height: calc(100vh - 220px); } .panel, -.canvas-panel, -.config-preview { - background: #ffffff; - border: 1px solid #d8dee5; +.canvas-panel { + border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); } .panel { - padding: 12px; + padding: 14px; } .panel-title { - margin-bottom: 10px; - font-size: 15px; - font-weight: 700; + margin: 2px 0 12px; + font-size: 16px; + font-weight: 900; } .region-list { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.region-button { + display: grid; + grid-template-columns: 12px minmax(0, 1fr) auto 28px; + align-items: center; gap: 8px; + min-height: 42px; + padding: 8px 9px; + text-align: left; + border-color: #d6dde4; + background: #fbfcfd; } -.region-list button.active { - background: #155eef; - border-color: #155eef; - color: #ffffff; +.region-button.active { + border-color: var(--region-color); + background: color-mix(in srgb, var(--region-color) 11%, white); } -.button-stack { +.region-button.complete .region-points { + border-color: rgba(15, 143, 97, 0.24); + background: #e9f8f1; + color: var(--green); +} + +.region-swatch { + width: 10px; + height: 22px; + border-radius: 999px; + background: var(--region-color); +} + +.region-name { + overflow: hidden; + font-size: 13px; + font-weight: 900; + text-overflow: ellipsis; + white-space: nowrap; +} + +.region-code { + color: var(--soft); + font-size: 11px; + font-weight: 900; +} + +.region-points { + display: grid; + place-items: center; + width: 28px; + height: 24px; + border: 1px solid #dbe2e8; + border-radius: 999px; + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.tool-stack { display: grid; gap: 8px; margin-top: 14px; } +.tool-stack button { + width: 100%; +} + .canvas-panel { - display: flex; + display: grid; + grid-template-rows: auto minmax(0, 1fr); min-width: 0; - min-height: 0; - align-items: center; - justify-content: center; + min-height: 540px; overflow: hidden; - background: #121826; + background: #0d171d; +} + +.canvas-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #91a6b4; + font-size: 11px; + font-weight: 900; + letter-spacing: 0; +} + +.canvas-toolbar strong { + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + color: #e9f8f9; + letter-spacing: 0; } canvas { width: 100%; height: 100%; + min-height: 0; object-fit: contain; cursor: crosshair; } @@ -188,130 +466,302 @@ canvas { gap: 8px; } -.region-summary div { - display: flex; - justify-content: space-between; +.summary-row { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + align-items: center; gap: 8px; - padding-bottom: 7px; - border-bottom: 1px solid #edf1f5; + min-height: 36px; + padding: 8px 0; + border-bottom: 1px solid #edf1f4; } -.region-summary span { - color: #667085; +.summary-row strong { + overflow: hidden; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary-row span:last-child { + color: var(--muted); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.summary-row.complete span:last-child { + color: var(--green); +} + +.summary-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--region-color); } .metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; - margin-bottom: 12px; } .metric { display: grid; gap: 8px; + min-height: 104px; padding: 14px; - background: #ffffff; - border: 1px solid #d8dee5; + border: 1px solid var(--line); + border-top: 4px solid #aeb9c4; border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); } .metric span { - color: #667085; - font-size: 13px; + color: var(--muted); + font-size: 12px; + font-weight: 900; } .metric strong { overflow-wrap: anywhere; - font-size: 18px; + font-family: "DIN Alternate", "Avenir Next Condensed", "PingFang SC", sans-serif; + font-size: 25px; + line-height: 1.1; + letter-spacing: 0; +} + +.metric.good { + border-top-color: var(--green); +} + +.metric.warning { + border-top-color: var(--amber); +} + +.metric.danger { + border-top-color: var(--red); +} + +.metric.path strong { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.35; } .metric.wide { grid-column: 1 / -1; + min-height: 78px; +} + +.event-panel { + overflow: hidden; } .events-table { overflow: auto; + margin: 0 -14px -14px; } table { width: 100%; + min-width: 760px; border-collapse: collapse; } th, td { - padding: 10px; - border-bottom: 1px solid #edf1f5; + padding: 11px 14px; + border-bottom: 1px solid #edf1f4; text-align: left; white-space: nowrap; } th { - color: #475467; + position: sticky; + top: 0; + background: #f4f7f9; + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +td { + color: #314250; font-size: 13px; } +.violation-row { + background: #fff6f5; +} + +.event-name { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 4px 8px; + border: 1px solid #d8e0e7; + border-radius: 999px; + background: #ffffff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + font-weight: 800; +} + +.event-name.danger { + border-color: rgba(201, 50, 50, 0.24); + background: #fdecea; + color: var(--red); +} + +.settings-layout { + display: grid; + grid-template-columns: minmax(360px, 0.9fr) minmax(420px, 1.1fr); + gap: 12px; + align-items: start; +} + .settings-grid { display: grid; - grid-template-columns: repeat(2, minmax(240px, 1fr)); + grid-template-columns: repeat(2, minmax(220px, 1fr)); gap: 12px; - margin-bottom: 12px; - padding: 12px; - background: #ffffff; - border: 1px solid #d8dee5; - border-radius: 8px; +} + +.settings-grid .field:first-child { + grid-column: 1 / -1; } .settings-actions { display: flex; - gap: 10px; - justify-content: flex-start; - margin-bottom: 12px; - padding: 12px; - background: #ffffff; - border: 1px solid #d8dee5; - border-radius: 8px; + gap: 9px; + flex-wrap: wrap; + margin-top: 14px; +} + +.preview-panel { + min-width: 0; } .config-preview { + min-height: 420px; + max-height: calc(100vh - 220px); margin: 0; - padding: 12px; - min-height: 320px; + padding: 14px; overflow: auto; + border: 1px solid #172631; + border-radius: 6px; + background: #0e1820; + color: #d7e8ef; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; + line-height: 1.55; } .empty { - padding: 24px; - color: #667085; + padding: 34px 14px; + color: var(--muted); + font-weight: 800; } -.hidden { - display: none; +@supports not (background: color-mix(in srgb, white, black)) { + .region-button.active { + background: #eef7f8; + } } -@media (max-width: 1100px) { - .header, - .toolbar { +@media (max-width: 1280px) { + .console-header { grid-template-columns: 1fr; } - .header { + .tabs { + justify-self: start; + } + + .header-actions { + justify-content: flex-start; + } + + .calibration-layout { + grid-template-columns: 240px minmax(420px, 1fr); + } + + .inspection-panel { + grid-column: 1 / -1; + } + + .region-summary { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 920px) { + .main { + padding: 12px; + } + + .view-head, + .header-actions, + .command-actions, + .settings-actions { align-items: stretch; flex-direction: column; } - .tabs { - flex-wrap: wrap; + .view-note { + max-width: none; + text-align: left; } - .calibration-grid { + .command-bar, + .calibration-layout, + .settings-layout, + .settings-grid, + .metrics { grid-template-columns: 1fr; } - .metrics, - .settings-grid { + .canvas-panel { + min-height: 420px; + } + + .region-summary { grid-template-columns: 1fr; } + + .status-pill { + max-width: none; + width: 100%; + } +} + +@media (max-width: 560px) { + .console-header { + padding: 12px; + } + + .tabs, + .command-actions { + width: 100%; + } + + .tabs { + display: grid; + grid-template-columns: 1fr; + } + + .command-actions button, + .header-actions button { + width: 100%; + } + + .view-head h1 { + font-size: 21px; + } + + .panel { + padding: 12px; + } }