Compare commits
3 Commits
08c5d2e955
...
ea5f9b1b07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea5f9b1b07 | ||
|
|
96f5c14a26 | ||
|
|
aff2b1828e |
@@ -2,7 +2,7 @@ camera_id = "cold_display_cam_01"
|
||||
timezone = "Asia/Shanghai"
|
||||
|
||||
[stream]
|
||||
rtsp_url = ""
|
||||
rtsp_url = "rtsp://admin:Zxjp2026@192.168.8.9:554/h264/ch1/main/av_stream"
|
||||
|
||||
[thresholds]
|
||||
max_dwell_seconds = 10800
|
||||
@@ -15,38 +15,38 @@ zone_ids = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"]
|
||||
|
||||
[[zones]]
|
||||
id = "r1c1"
|
||||
polygon = [[0.00, 0.00], [0.25, 0.00], [0.25, 0.50], [0.00, 0.50]]
|
||||
polygon = [[0.441053, 0.344678], [0.475789, 0.372749], [0.453684, 0.455088], [0.404211, 0.428889]]
|
||||
|
||||
[[zones]]
|
||||
id = "r1c2"
|
||||
polygon = [[0.25, 0.00], [0.50, 0.00], [0.50, 0.50], [0.25, 0.50]]
|
||||
polygon = [[0.486316, 0.367135], [0.520000, 0.397076], [0.503158, 0.468187], [0.467368, 0.451345]]
|
||||
|
||||
[[zones]]
|
||||
id = "r1c3"
|
||||
polygon = [[0.50, 0.00], [0.75, 0.00], [0.75, 0.50], [0.50, 0.50]]
|
||||
polygon = [[0.545263, 0.400819], [0.587368, 0.417661], [0.554737, 0.500000], [0.509474, 0.483158]]
|
||||
|
||||
[[zones]]
|
||||
id = "r1c4"
|
||||
polygon = [[0.75, 0.00], [1.00, 0.00], [1.00, 0.50], [0.75, 0.50]]
|
||||
polygon = [[0.581255, 0.408928], [0.717971, 0.468544], [0.711092, 0.574018], [0.556320, 0.500645]]
|
||||
|
||||
[[zones]]
|
||||
id = "r2c1"
|
||||
polygon = [[0.00, 0.50], [0.25, 0.50], [0.25, 1.00], [0.00, 1.00]]
|
||||
polygon = [[0.396842, 0.475673], [0.487368, 0.543041], [0.472632, 0.612281], [0.373684, 0.584211]]
|
||||
|
||||
[[zones]]
|
||||
id = "r2c2"
|
||||
polygon = [[0.25, 0.50], [0.50, 0.50], [0.50, 1.00], [0.25, 1.00]]
|
||||
polygon = [[0.502105, 0.528070], [0.535789, 0.546784], [0.516842, 0.660936], [0.477895, 0.632865]]
|
||||
|
||||
[[zones]]
|
||||
id = "r2c3"
|
||||
polygon = [[0.50, 0.50], [0.75, 0.50], [0.75, 1.00], [0.50, 1.00]]
|
||||
polygon = [[0.555789, 0.552398], [0.602105, 0.569240], [0.580000, 0.657193], [0.535789, 0.645965]]
|
||||
|
||||
[[zones]]
|
||||
id = "r2c4"
|
||||
polygon = [[0.75, 0.50], [1.00, 0.50], [1.00, 1.00], [0.75, 1.00]]
|
||||
polygon = [[0.602105, 0.567368], [0.700000, 0.606667], [0.689474, 0.722690], [0.581053, 0.683392]]
|
||||
|
||||
[trash]
|
||||
roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]]
|
||||
roi = [[0.776842, 0.486901], [0.896842, 0.522456], [0.841053, 0.857427], [0.716842, 0.853684]]
|
||||
|
||||
[event_sink]
|
||||
path = "logs/events.jsonl"
|
||||
|
||||
61
docs/plans/2026-04-29-web-industrial-console-design.md
Normal file
61
docs/plans/2026-04-29-web-industrial-console-design.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Cold Display Guard Web Industrial Console Design
|
||||
|
||||
**Date:** 2026-04-29
|
||||
|
||||
## Goal
|
||||
|
||||
Redesign the existing management frontend as an industrial operations console for refrigerated display monitoring, while preserving the current Vite single-page app, management API contract, and calibration workflow.
|
||||
|
||||
## Direction
|
||||
|
||||
Use a dense, production-oriented control-room interface. The application should feel like a tool used by operators who repeatedly calibrate zones, check runtime health, inspect alerts, and update camera settings. It should avoid a marketing-page layout and prioritize scanning, comparison, and fast action.
|
||||
|
||||
## Information Architecture
|
||||
|
||||
The app keeps the existing three views:
|
||||
|
||||
- `区域标定`: primary working view for RTSP capture and polygon editing.
|
||||
- `事件数据`: monitoring view for runtime metrics and recent JSONL events.
|
||||
- `运行配置`: configuration view for camera identity, RTSP URL, thresholds, and JSON preview.
|
||||
|
||||
The top bar becomes a compact console header with the product name, operational subtitle, connection/status message, and refresh action. Tabs remain visible but are styled as segmented navigation.
|
||||
|
||||
## Calibration View
|
||||
|
||||
The captured camera frame is the visual center of the page. The region selector and point editing tools sit beside it as operator controls, while calibration completeness and per-region point counts sit in a right-side inspection panel.
|
||||
|
||||
The canvas keeps its current behavior:
|
||||
|
||||
- RTSP snapshot must be captured before adding points.
|
||||
- Clicks add normalized polygon points to the active region.
|
||||
- Local draft storage is preserved.
|
||||
- Saved calibration uses `PUT /api/manage/calibration`.
|
||||
|
||||
## Events View
|
||||
|
||||
Metrics appear as compact telemetry cards, with violation count and baseline state visually distinct. The event table remains the primary data surface, with rows styled for scanability and violation event names emphasized.
|
||||
|
||||
## Settings View
|
||||
|
||||
Configuration uses a two-column control layout with an adjacent JSON preview. The UI should make production parameters easy to inspect without changing the existing `PUT /api/manage/config` payload.
|
||||
|
||||
## Visual System
|
||||
|
||||
The console uses a cool industrial palette:
|
||||
|
||||
- charcoal and steel for structure
|
||||
- white panels for data surfaces
|
||||
- cyan/green for ready or active state
|
||||
- amber for learning or pending state
|
||||
- red for violations and failures
|
||||
|
||||
Controls use restrained radius, clear focus states, high contrast, and predictable spacing. The design avoids purple gradients, oversized hero sections, decorative cards, and purely ornamental backgrounds.
|
||||
|
||||
## Implementation Scope
|
||||
|
||||
Modify only the web frontend:
|
||||
|
||||
- `web/src/main.js`
|
||||
- `web/src/styles.css`
|
||||
|
||||
Do not change API routes, backend behavior, deployment files, or existing unrelated workspace changes.
|
||||
61
docs/plans/2026-04-29-web-industrial-console.md
Normal file
61
docs/plans/2026-04-29-web-industrial-console.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Web Industrial Console Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Redesign the Cold Display Guard management frontend into a dense industrial control console without changing the backend API.
|
||||
|
||||
**Architecture:** Keep the existing Vite app and vanilla JavaScript state model. Replace the static HTML shell with a more structured console layout, then update render helpers to emit richer classes for status, calibration completeness, and event severity. Implement the visual system in `web/src/styles.css`.
|
||||
|
||||
**Tech Stack:** Vite 5, vanilla JavaScript modules, HTML canvas, CSS custom properties.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Restructure Console Shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/main.js`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Replace the current `app.innerHTML` shell with a console header, segmented tabs, status pill, and view-specific panels.
|
||||
2. Preserve all existing element IDs used by JavaScript event handlers.
|
||||
3. Keep the three views: `calibrationView`, `eventsView`, and `settingsView`.
|
||||
4. Run `pnpm --dir web build`.
|
||||
|
||||
### Task 2: Add UI State Classes
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/main.js`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Update `renderRegionList()` to include region color swatches, labels, active state, and completion state.
|
||||
2. Update `renderRegionSummary()` to emit completion classes.
|
||||
3. Update `renderMetrics()` to classify baseline and violation cards.
|
||||
4. Update `renderEvents()` to classify violation rows.
|
||||
5. Update `setStatus()` to classify success, error, and progress states.
|
||||
6. Run `pnpm --dir web build`.
|
||||
|
||||
### Task 3: Implement Industrial Visual System
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/styles.css`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Replace the existing generic styles with CSS variables for the industrial console palette.
|
||||
2. Style the header, tabs, status strip, controls, panels, calibration canvas, metrics, tables, and settings layout.
|
||||
3. Add responsive behavior for tablet and mobile widths.
|
||||
4. Confirm text does not overflow compact controls.
|
||||
5. Run `pnpm --dir web build`.
|
||||
|
||||
### Task 4: Verify
|
||||
|
||||
**Files:**
|
||||
- Read: `web/dist/index.html`
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Run `pnpm --dir web build`.
|
||||
2. Confirm Vite emits `web/dist`.
|
||||
3. Review `git diff -- web/src/main.js web/src/styles.css docs/plans/2026-04-29-web-industrial-console-design.md docs/plans/2026-04-29-web-industrial-console.md`.
|
||||
233
web/src/main.js
233
web/src/main.js
@@ -33,51 +33,74 @@ const app = document.querySelector("#app");
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="shell">
|
||||
<header class="header">
|
||||
<header class="console-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">CD</div>
|
||||
<div>
|
||||
<div class="brand-mark">CG</div>
|
||||
<div class="brand-copy">
|
||||
<div class="brand-kicker">COLD DISPLAY GUARD</div>
|
||||
<div class="brand-title">冷藏展示柜管理</div>
|
||||
<div class="brand-subtitle">标定、配置、事件数据</div>
|
||||
<div class="brand-subtitle">区域标定 / 运行监控 / 合规事件</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="calibration">区域标定</button>
|
||||
<button data-tab="events">事件数据</button>
|
||||
<button data-tab="settings">运行配置</button>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="status-pill" id="statusPill">
|
||||
<span class="status-dot"></span>
|
||||
<span id="statusText"></span>
|
||||
</div>
|
||||
<button id="refreshRuntimeData" class="secondary-action" type="button">刷新运行数据</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<section class="status-line">
|
||||
<span id="statusText"></span>
|
||||
<button id="refreshRuntimeData" type="button">刷新运行数据</button>
|
||||
</section>
|
||||
|
||||
<section id="calibrationView" class="view">
|
||||
<section class="toolbar">
|
||||
<label>
|
||||
RTSP 地址(用于抓帧)
|
||||
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||
</label>
|
||||
<button id="captureSnapshot" type="button">抓取一帧</button>
|
||||
<button id="saveCalibration" type="button">保存标定</button>
|
||||
<section class="view-head">
|
||||
<div>
|
||||
<p class="view-kicker">CALIBRATION</p>
|
||||
<h1>冷柜区域标定</h1>
|
||||
</div>
|
||||
<p class="view-note">抓取一帧后,在画面中依次点击每个格口和垃圾桶 ROI 的边界点。</p>
|
||||
</section>
|
||||
|
||||
<section class="calibration-grid">
|
||||
<aside class="panel">
|
||||
<div class="panel-title">区域</div>
|
||||
<section class="command-bar">
|
||||
<label class="field rtsp-field">
|
||||
<span>RTSP 地址(用于抓帧)</span>
|
||||
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||
</label>
|
||||
<div class="command-actions">
|
||||
<button id="captureSnapshot" class="primary-action" type="button">抓取一帧</button>
|
||||
<button id="saveCalibration" class="success-action" type="button">保存标定</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="calibration-layout">
|
||||
<aside class="panel zone-panel">
|
||||
<div class="panel-meta">ZONE MATRIX</div>
|
||||
<div class="panel-title">区域选择</div>
|
||||
<div id="regionList" class="region-list"></div>
|
||||
<div class="button-stack">
|
||||
<div class="tool-stack">
|
||||
<button id="undoPoint" type="button">撤销点</button>
|
||||
<button id="clearRegion" type="button">清空当前区域</button>
|
||||
<button id="loadConfigPolygons" type="button">放弃草稿并载入已保存标定</button>
|
||||
<button id="loadConfigPolygons" type="button">载入已保存标定</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-panel">
|
||||
<div class="canvas-toolbar">
|
||||
<span>FRAME INSPECTION</span>
|
||||
<strong id="activeRegionBadge">r1c1</strong>
|
||||
</div>
|
||||
<canvas id="canvas" width="1280" height="720"></canvas>
|
||||
</section>
|
||||
<aside class="panel">
|
||||
|
||||
<aside class="panel inspection-panel">
|
||||
<div class="panel-meta">POLYGON STATUS</div>
|
||||
<div class="panel-title">标定结果</div>
|
||||
<div id="regionSummary" class="region-summary"></div>
|
||||
</aside>
|
||||
@@ -85,41 +108,66 @@ app.innerHTML = `
|
||||
</section>
|
||||
|
||||
<section id="eventsView" class="view hidden">
|
||||
<section class="view-head">
|
||||
<div>
|
||||
<p class="view-kicker">RUNTIME</p>
|
||||
<h1>事件与运行状态</h1>
|
||||
</div>
|
||||
<p class="view-note">从运行进程写入的事件和诊断数据中读取最近状态。</p>
|
||||
</section>
|
||||
<section class="metrics" id="metrics"></section>
|
||||
<section class="panel">
|
||||
<section class="panel event-panel">
|
||||
<div class="panel-meta">EVENT LOG</div>
|
||||
<div class="panel-title">最近事件</div>
|
||||
<div id="eventsTable" class="events-table"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="settingsView" class="view hidden">
|
||||
<section class="settings-grid">
|
||||
<label>
|
||||
RTSP 地址
|
||||
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||
</label>
|
||||
<label>
|
||||
Camera ID
|
||||
<input id="cameraId" type="text">
|
||||
</label>
|
||||
<label>
|
||||
时区
|
||||
<input id="timezone" type="text">
|
||||
</label>
|
||||
<label>
|
||||
最大放置秒数
|
||||
<input id="maxDwell" type="number" min="1">
|
||||
</label>
|
||||
<label>
|
||||
垃圾桶确认秒数
|
||||
<input id="trashWindow" type="number" min="1">
|
||||
</label>
|
||||
<section class="view-head">
|
||||
<div>
|
||||
<p class="view-kicker">CONFIGURATION</p>
|
||||
<h1>运行配置</h1>
|
||||
</div>
|
||||
<p class="view-note">保存后写入后端配置;运行进程需要按配置重新读取。</p>
|
||||
</section>
|
||||
<section class="settings-actions">
|
||||
<button id="saveConfig" type="button">保存运行配置</button>
|
||||
<button id="reloadConfig" type="button">重新载入后端配置</button>
|
||||
|
||||
<section class="settings-layout">
|
||||
<section class="panel settings-panel">
|
||||
<div class="panel-meta">CAMERA INPUT</div>
|
||||
<div class="settings-grid">
|
||||
<label class="field">
|
||||
<span>RTSP 地址</span>
|
||||
<input id="settingsRtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Camera ID</span>
|
||||
<input id="cameraId" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>时区</span>
|
||||
<input id="timezone" type="text">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>最大放置秒数</span>
|
||||
<input id="maxDwell" type="number" min="1">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>垃圾桶确认秒数</span>
|
||||
<input id="trashWindow" type="number" min="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button id="saveConfig" class="success-action" type="button">保存运行配置</button>
|
||||
<button id="reloadConfig" type="button">重新载入后端配置</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel preview-panel">
|
||||
<div class="panel-meta">LIVE CONFIG PREVIEW</div>
|
||||
<pre id="configPreview" class="config-preview"></pre>
|
||||
</section>
|
||||
</section>
|
||||
<pre id="configPreview" class="config-preview"></pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
@@ -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");
|
||||
|
||||
@@ -300,12 +350,12 @@ async function saveCalibration() {
|
||||
|
||||
async function persistCalibration({requireAny}) {
|
||||
const zones = zoneIds
|
||||
.map((id) => ({id, polygon: state.polygons[id]}))
|
||||
.map((id) => ({id, polygon: serializePolygon(state.polygons[id])}))
|
||||
.filter((zone) => zone.polygon.length >= 3);
|
||||
const trashPolygon = state.polygons.trash;
|
||||
const payload = {zones, trash: {}};
|
||||
if (trashPolygon.length >= 3) {
|
||||
payload.trash.roi = trashPolygon;
|
||||
payload.trash.roi = serializePolygon(trashPolygon);
|
||||
}
|
||||
if (!zones.length && !payload.trash.roi) {
|
||||
if (requireAny) {
|
||||
@@ -320,6 +370,10 @@ async function persistCalibration({requireAny}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function serializePolygon(points) {
|
||||
return points.map((point) => [point.x, point.y]);
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
state.activeTab = tab;
|
||||
document.querySelectorAll(".tabs button").forEach((button) => {
|
||||
@@ -377,9 +431,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 = `
|
||||
<span class="region-swatch"></span>
|
||||
<span class="region-name">${escapeHtml(getRegionLabel(id))}</span>
|
||||
<span class="region-code">${escapeHtml(id)}</span>
|
||||
<span class="region-points">${state.polygons[id].length}</span>
|
||||
`;
|
||||
button.addEventListener("click", () => {
|
||||
state.activeRegion = id;
|
||||
render();
|
||||
@@ -538,28 +603,45 @@ function renderRegionSummary() {
|
||||
els.regionSummary.innerHTML = allRegions
|
||||
.map((id) => {
|
||||
const count = state.polygons[id].length;
|
||||
return `<div><strong>${id}</strong><span>${count >= 3 ? `${count} 点,已标定` : `${count} 点`}</span></div>`;
|
||||
const complete = count >= 3;
|
||||
return `
|
||||
<div class="summary-row ${complete ? "complete" : "pending"}">
|
||||
<span class="summary-dot" style="--region-color:${palette[id] || "#ffffff"}"></span>
|
||||
<strong>${escapeHtml(getRegionLabel(id))}</strong>
|
||||
<span>${complete ? `${count} 点 / 已标定` : `${count} 点 / 待完成`}</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.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
|
||||
? `<div class="metric wide"><span>最新区域状态</span><strong>${Object.entries(zoneCounts)
|
||||
? `<div class="metric wide zone-state"><span>最新区域状态</span><strong>${Object.entries(zoneCounts)
|
||||
.map(([zoneId, count]) => `${zoneId}:${count}`)
|
||||
.join(" ")}</strong></div>`
|
||||
: "";
|
||||
els.metrics.innerHTML = cards.map(([label, value]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("") + zoneSummary;
|
||||
els.metrics.innerHTML = cards
|
||||
.map((card) => `
|
||||
<div class="metric ${card.tone}">
|
||||
<span>${escapeHtml(card.label)}</span>
|
||||
<strong>${escapeHtml(String(card.value))}</strong>
|
||||
</div>
|
||||
`)
|
||||
.join("") + zoneSummary;
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
@@ -574,15 +656,19 @@ function renderEvents() {
|
||||
${state.events
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((event) => `
|
||||
<tr>
|
||||
.map((event) => {
|
||||
const eventName = event.event || "";
|
||||
const isViolation = eventName.includes("violation");
|
||||
return `
|
||||
<tr class="${isViolation ? "violation-row" : ""}">
|
||||
<td>${escapeHtml(event.ts || "")}</td>
|
||||
<td>${escapeHtml(event.event || "")}</td>
|
||||
<td><span class="event-name ${isViolation ? "danger" : ""}">${escapeHtml(eventName)}</span></td>
|
||||
<td>${escapeHtml(event.zone_id || "")}</td>
|
||||
<td>${escapeHtml(event.batch_id || "")}</td>
|
||||
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
|
||||
</tr>
|
||||
`)
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -627,6 +713,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user