diff --git a/.gitignore b/.gitignore
index 8d49296..a71bcb5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ build/
*.egg-info/
logs/
*.jsonl
+web/node_modules/
+web/dist/
diff --git a/README_zh.md b/README_zh.md
index 97cd5ed..d1bac16 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -51,22 +51,55 @@
## 区域标定
-项目内置一个本地 Web 标定工具,可以从 RTSP 拉取一帧截图,再用鼠标标定 8 个格口和垃圾桶区域:
+项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`。
```bash
-python3 tools/calibrator/server.py --host 127.0.0.1 --port 18090
+scripts/run_manage_api.sh
+```
+
+另开一个终端:
+
+```bash
+scripts/run_web.sh
```
打开:
```text
-http://127.0.0.1:18090
+http://127.0.0.1:23000
```
-详细说明见 `tools/calibrator/README_zh.md`。
+管理页支持:
+
+- 配置 RTSP 地址和阈值
+- 从 RTSP 拉取一帧截图
+- 标定 `r1c1` 到 `r2c4` 的 8 个格口
+- 标定垃圾桶区域
+- 直接保存标定结果到项目配置文件
+- 查看事件汇总和最近 JSONL 事件
+
+项目仍保留 `tools/calibrator` 作为轻量单页标定工具,但正式使用建议走 `23000` 管理页。
+
+## 管理 API
+
+默认后端:
+
+```text
+http://127.0.0.1:19080
+```
+
+主要接口:
+
+- `GET /api/manage/health`
+- `GET /api/manage/config`
+- `PUT /api/manage/config`
+- `POST /api/manage/snapshot`
+- `PUT /api/manage/calibration`
+- `GET /api/manage/summary`
+- `GET /api/manage/events`
## 本地测试
```bash
-python3 -m unittest discover -s tests -v
+PYTHONPATH=src python3 -m unittest discover -s tests -v
```
diff --git a/config/example.toml b/config/example.toml
index 234aa3b..4d6769c 100644
--- a/config/example.toml
+++ b/config/example.toml
@@ -1,6 +1,9 @@
camera_id = "cold_display_cam_01"
timezone = "Asia/Shanghai"
+[stream]
+rtsp_url = ""
+
[thresholds]
max_dwell_seconds = 10800
trash_confirmation_seconds = 120
@@ -44,3 +47,6 @@ polygon = [[0.75, 0.50], [1.00, 0.50], [1.00, 1.00], [0.75, 1.00]]
[trash]
roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]]
+
+[event_sink]
+path = "logs/events.jsonl"
diff --git a/docs/plans/2026-04-27-cold-display-guard-design.md b/docs/plans/2026-04-27-cold-display-guard-design.md
index c69a663..b699eb9 100644
--- a/docs/plans/2026-04-27-cold-display-guard-design.md
+++ b/docs/plans/2026-04-27-cold-display-guard-design.md
@@ -114,7 +114,20 @@ Trash disposal confirmation should use motion/object evidence inside the trash R
## Calibration Tool
-The project includes a local RTSP snapshot calibration tool under `tools/calibrator`.
+The project includes a managed web UI with frontend port `23000` and backend port `19080`.
+
+The backend exposes:
+
+- `GET /api/manage/config`
+- `PUT /api/manage/config`
+- `POST /api/manage/snapshot`
+- `PUT /api/manage/calibration`
+- `GET /api/manage/summary`
+- `GET /api/manage/events`
+
+The frontend can pull one RTSP snapshot, draw polygons for `r1c1` through `r2c4` and `trash`, then save calibration directly to the project TOML config.
+
+The project also keeps a lightweight local RTSP snapshot calibration tool under `tools/calibrator`.
The tool runs a small standard-library HTTP server. The browser submits an RTSP URL to `/api/capture`; the server calls `ffmpeg`, extracts one JPEG frame, and returns it to the browser. The page then lets the operator draw normalized polygons for `r1c1` through `r2c4` plus `trash`.
diff --git a/progress.md b/progress.md
index acffb6c..3cfb2b0 100644
--- a/progress.md
+++ b/progress.md
@@ -10,3 +10,4 @@
- Test suite now passes: `PYTHONPATH=src python3 -m unittest discover -s tests -v`.
- Initialized git repository and created the initial project commit.
- Added RTSP single-frame calibration tool under `tools/calibrator`.
+- Added formal management API on port `19080` and Vite frontend on port `23000`.
diff --git a/pyproject.toml b/pyproject.toml
index c18e7b9..1604743 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ dependencies = []
[project.scripts]
cold-display-guard = "cold_display_guard.cli:main"
+cold-display-guard-manage = "cold_display_guard.manage_api:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/scripts/run_manage_api.sh b/scripts/run_manage_api.sh
new file mode 100755
index 0000000..105fd27
--- /dev/null
+++ b/scripts/run_manage_api.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+CONFIG_PATH="${CONFIG_PATH:-config/example.toml}"
+HOST="${HOST:-127.0.0.1}"
+PORT="${PORT:-19080}"
+
+PYTHONPATH=src python3 -m cold_display_guard.manage_api --config "$CONFIG_PATH" --host "$HOST" --port "$PORT"
diff --git a/scripts/run_web.sh b/scripts/run_web.sh
new file mode 100755
index 0000000..f163f58
--- /dev/null
+++ b/scripts/run_web.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd web
+pnpm dev --host 127.0.0.1 --port 23000
diff --git a/src/cold_display_guard/config.py b/src/cold_display_guard/config.py
index 6ff0763..25b0c56 100644
--- a/src/cold_display_guard/config.py
+++ b/src/cold_display_guard/config.py
@@ -1,14 +1,18 @@
from __future__ import annotations
import tomllib
+from copy import deepcopy
from pathlib import Path
from typing import Any
from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings
+DEFAULT_CONFIG_PATH = Path("config/example.toml")
+
+
def load_settings(path: str | Path) -> EngineSettings:
- data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
+ data = load_config_document(path)
thresholds: dict[str, Any] = data.get("thresholds", {})
layout: dict[str, Any] = data.get("layout", {})
@@ -24,9 +28,137 @@ def load_settings(path: str | Path) -> EngineSettings:
)
+def load_config_document(path: str | Path) -> dict[str, Any]:
+ config_path = Path(path)
+ return tomllib.loads(config_path.read_text(encoding="utf-8"))
+
+
+def save_config_document(path: str | Path, data: dict[str, Any]) -> None:
+ config_path = Path(path)
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(format_config_document(data), encoding="utf-8")
+
+
+def resolve_config_path(path: str | Path | None = None) -> Path:
+ if path is None:
+ path = DEFAULT_CONFIG_PATH
+ return Path(path).expanduser().resolve()
+
+
+def resolve_project_root(config_path: str | Path) -> Path:
+ path = Path(config_path).expanduser().resolve()
+ if path.parent.name == "config":
+ return path.parent.parent
+ return Path.cwd().resolve()
+
+
+def merge_calibration(
+ data: dict[str, Any],
+ zones: list[dict[str, Any]],
+ trash_roi: list[list[float]] | None,
+) -> dict[str, Any]:
+ merged = deepcopy(data)
+ valid_zones = []
+ for zone in zones:
+ zone_id = str(zone.get("id", "")).strip()
+ polygon = _normalize_points(zone.get("polygon", []))
+ if not zone_id or len(polygon) < 3:
+ continue
+ valid_zones.append({"id": zone_id, "polygon": polygon})
+
+ if valid_zones:
+ merged["zones"] = valid_zones
+ layout = merged.setdefault("layout", {})
+ layout["zone_ids"] = [zone["id"] for zone in valid_zones]
+
+ if trash_roi is not None:
+ normalized_roi = _normalize_points(trash_roi)
+ if len(normalized_roi) >= 3:
+ trash = merged.setdefault("trash", {})
+ trash["roi"] = normalized_roi
+
+ return merged
+
+
+def format_config_document(data: dict[str, Any]) -> str:
+ lines: list[str] = []
+ lines.append(f'camera_id = "{_escape(str(data.get("camera_id", "cold_display_cam_01")))}"')
+ lines.append(f'timezone = "{_escape(str(data.get("timezone", "Asia/Shanghai")))}"')
+ lines.append("")
+
+ stream = data.get("stream", {})
+ lines.append("[stream]")
+ lines.append(f'rtsp_url = "{_escape(str(stream.get("rtsp_url", "")))}"')
+ lines.append("")
+
+ thresholds = data.get("thresholds", {})
+ lines.append("[thresholds]")
+ lines.append(f'max_dwell_seconds = {int(thresholds.get("max_dwell_seconds", 10_800))}')
+ lines.append(f'trash_confirmation_seconds = {int(thresholds.get("trash_confirmation_seconds", 120))}')
+ lines.append("")
+
+ layout = data.get("layout", {})
+ zone_ids = [str(item) for item in layout.get("zone_ids", DEFAULT_ZONE_IDS)]
+ rows = int(layout.get("rows", 2))
+ cols = int(layout.get("cols", 4))
+ lines.append("[layout]")
+ lines.append(f"rows = {rows}")
+ lines.append(f"cols = {cols}")
+ lines.append(f"zone_ids = {_format_string_array(zone_ids)}")
+ lines.append("")
+
+ for zone in data.get("zones", []):
+ zone_id = str(zone.get("id", "")).strip()
+ polygon = _normalize_points(zone.get("polygon", []))
+ if not zone_id or len(polygon) < 3:
+ continue
+ lines.append("[[zones]]")
+ lines.append(f'id = "{_escape(zone_id)}"')
+ lines.append(f"polygon = {_format_points(polygon)}")
+ lines.append("")
+
+ trash = data.get("trash", {})
+ roi = _normalize_points(trash.get("roi", []))
+ if len(roi) >= 3:
+ lines.append("[trash]")
+ lines.append(f"roi = {_format_points(roi)}")
+ lines.append("")
+
+ event_sink = data.get("event_sink", {})
+ lines.append("[event_sink]")
+ lines.append(f'path = "{_escape(str(event_sink.get("path", "logs/events.jsonl")))}"')
+ lines.append("")
+ return "\n".join(lines)
+
+
def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
rows = int(layout.get("rows", 0))
cols = int(layout.get("cols", 0))
if rows <= 0 or cols <= 0:
return ()
return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1))
+
+
+def _normalize_points(value: Any) -> list[list[float]]:
+ points: list[list[float]] = []
+ if not isinstance(value, list):
+ return points
+ for item in value:
+ if not isinstance(item, list | tuple) or len(item) != 2:
+ continue
+ x = min(1.0, max(0.0, float(item[0])))
+ y = min(1.0, max(0.0, float(item[1])))
+ points.append([round(x, 6), round(y, 6)])
+ return points
+
+
+def _format_points(points: list[list[float]]) -> str:
+ return "[" + ", ".join(f"[{x:.6f}, {y:.6f}]" for x, y in points) + "]"
+
+
+def _format_string_array(values: list[str]) -> str:
+ return "[" + ", ".join(f'"{_escape(value)}"' for value in values) + "]"
+
+
+def _escape(value: str) -> str:
+ return value.replace("\\", "\\\\").replace('"', '\\"')
diff --git a/src/cold_display_guard/manage_api.py b/src/cold_display_guard/manage_api.py
new file mode 100644
index 0000000..937a8c9
--- /dev/null
+++ b/src/cold_display_guard/manage_api.py
@@ -0,0 +1,351 @@
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+from cold_display_guard.config import (
+ load_config_document,
+ merge_calibration,
+ resolve_config_path,
+ resolve_project_root,
+ save_config_document,
+)
+
+
+PROJECT_TYPE = "cold_display_guard"
+DEFAULT_MANAGE_PORT = 19080
+MAX_EVENT_LINES = 2000
+
+
+@dataclass(frozen=True, slots=True)
+class ManageContext:
+ config_path: Path
+ project_root: Path
+
+
+class CaptureError(RuntimeError):
+ pass
+
+
+def create_handler(ctx: ManageContext) -> type[BaseHTTPRequestHandler]:
+ class ManageHandler(BaseHTTPRequestHandler):
+ server_version = "ColdDisplayGuardManage/0.1"
+
+ def do_OPTIONS(self) -> None:
+ self._send_empty(HTTPStatus.NO_CONTENT)
+
+ def do_GET(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == "/api/manage/health":
+ self._send_json(
+ {
+ "status": "ok",
+ "project_type": PROJECT_TYPE,
+ "version": "dev",
+ "runtime_status": "running",
+ }
+ )
+ return
+ if parsed.path == "/api/manage/config":
+ self._send_json(config_payload(ctx))
+ return
+ if parsed.path == "/api/manage/summary":
+ self._send_json(build_summary(ctx))
+ return
+ if parsed.path == "/api/manage/events":
+ query = parse_qs(parsed.query)
+ limit = bounded_int(query.get("limit", ["200"])[0], 1, MAX_EVENT_LINES)
+ self._send_json({"items": load_events(ctx, limit), "limit": limit})
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def do_PUT(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == "/api/manage/config":
+ self._update_config()
+ return
+ if parsed.path == "/api/manage/calibration":
+ self._save_calibration()
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def do_POST(self) -> None:
+ parsed = urlparse(self.path)
+ if parsed.path == "/api/manage/snapshot":
+ self._capture_snapshot()
+ return
+ self.send_error(HTTPStatus.NOT_FOUND)
+
+ def log_message(self, format: str, *args: object) -> None:
+ print(f"{self.address_string()} - {format % args}")
+
+ def _update_config(self) -> None:
+ payload = self._read_json()
+ data = load_config_document(ctx.config_path)
+
+ if "camera_id" in payload:
+ data["camera_id"] = str(payload["camera_id"]).strip() or "cold_display_cam_01"
+ if "timezone" in payload:
+ data["timezone"] = str(payload["timezone"]).strip() or "Asia/Shanghai"
+ if "rtsp_url" in payload:
+ stream = data.setdefault("stream", {})
+ stream["rtsp_url"] = str(payload["rtsp_url"]).strip()
+ if "thresholds" in payload and isinstance(payload["thresholds"], dict):
+ thresholds = data.setdefault("thresholds", {})
+ for key in ("max_dwell_seconds", "trash_confirmation_seconds"):
+ if key in payload["thresholds"]:
+ thresholds[key] = max(1, int(payload["thresholds"][key]))
+
+ save_config_document(ctx.config_path, data)
+ self._send_json(config_payload(ctx))
+
+ def _save_calibration(self) -> None:
+ payload = self._read_json()
+ zones = payload.get("zones", [])
+ trash = payload.get("trash", {})
+ if not isinstance(zones, list):
+ self._send_json({"error": "zones must be a list"}, HTTPStatus.BAD_REQUEST)
+ return
+ trash_roi = trash.get("roi") if isinstance(trash, dict) else None
+ data = load_config_document(ctx.config_path)
+ merged = merge_calibration(data, zones, trash_roi)
+ save_config_document(ctx.config_path, merged)
+ self._send_json(config_payload(ctx))
+
+ def _capture_snapshot(self) -> None:
+ payload = self._read_json()
+ rtsp_url = str(payload.get("rtsp_url", "")).strip()
+ if not rtsp_url:
+ rtsp_url = str(load_config_document(ctx.config_path).get("stream", {}).get("rtsp_url", "")).strip()
+ if not rtsp_url.lower().startswith("rtsp://"):
+ self._send_json({"error": "valid rtsp_url is required"}, HTTPStatus.BAD_REQUEST)
+ return
+ try:
+ image = capture_rtsp_frame(rtsp_url, float(payload.get("timeout_seconds", 12)))
+ except CaptureError as exc:
+ self._send_json({"error": str(exc)}, HTTPStatus.BAD_GATEWAY)
+ return
+
+ self.send_response(HTTPStatus.OK)
+ self._send_common_headers("image/jpeg")
+ self.send_header("Content-Length", str(len(image)))
+ self.end_headers()
+ self.wfile.write(image)
+
+ def _read_json(self) -> dict[str, Any]:
+ length = int(self.headers.get("Content-Length", "0"))
+ if length == 0:
+ return {}
+ try:
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
+ except json.JSONDecodeError as exc:
+ raise ValueError(f"invalid json: {exc}") from exc
+ if not isinstance(payload, dict):
+ raise ValueError("json payload must be an object")
+ return payload
+
+ def _send_json(self, payload: dict[str, Any], status: int = HTTPStatus.OK) -> None:
+ data = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
+ self.send_response(status)
+ self._send_common_headers("application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def _send_empty(self, status: int) -> None:
+ self.send_response(status)
+ self._send_common_headers("text/plain; charset=utf-8")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ def _send_common_headers(self, content_type: str) -> None:
+ self.send_header("Content-Type", content_type)
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+
+ return ManageHandler
+
+
+def run_manage_api(
+ config_path: str | Path | None = None,
+ host: str = "0.0.0.0",
+ port: int = DEFAULT_MANAGE_PORT,
+) -> None:
+ resolved_config = resolve_config_path(config_path)
+ ctx = ManageContext(
+ config_path=resolved_config,
+ project_root=resolve_project_root(resolved_config),
+ )
+ server = ThreadingHTTPServer((host, port), create_handler(ctx))
+ print(f"Cold Display Guard management API: http://{host}:{port}")
+ print(f"Config path: {resolved_config}")
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nStopping management API")
+ finally:
+ server.server_close()
+
+
+def parse_args() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Cold Display Guard management API")
+ parser.add_argument("--config", default=str(resolve_config_path(None)), help="Path to TOML config")
+ parser.add_argument("--host", default="0.0.0.0", help="Host for the management API")
+ parser.add_argument("--port", type=int, default=DEFAULT_MANAGE_PORT, help="Port for the management API")
+ return parser
+
+
+def main() -> int:
+ parser = parse_args()
+ args = parser.parse_args()
+ run_manage_api(args.config, host=args.host, port=args.port)
+ return 0
+
+
+def config_payload(ctx: ManageContext) -> dict[str, Any]:
+ data = load_config_document(ctx.config_path)
+ event_path = event_sink_path(ctx, data)
+ return {
+ "project_type": PROJECT_TYPE,
+ "config_path": str(ctx.config_path),
+ "camera_id": data.get("camera_id", "cold_display_cam_01"),
+ "timezone": data.get("timezone", "Asia/Shanghai"),
+ "stream": data.get("stream", {"rtsp_url": ""}),
+ "thresholds": data.get(
+ "thresholds",
+ {"max_dwell_seconds": 10_800, "trash_confirmation_seconds": 120},
+ ),
+ "layout": data.get("layout", {}),
+ "zones": data.get("zones", []),
+ "trash": data.get("trash", {}),
+ "event_sink": {"path": str(event_path)},
+ }
+
+
+def build_summary(ctx: ManageContext) -> dict[str, Any]:
+ events = load_events(ctx, MAX_EVENT_LINES)
+ counts: dict[str, int] = {}
+ last_event_time = ""
+ latest_alert = ""
+ for event in events:
+ event_name = str(event.get("event", "unknown"))
+ counts[event_name] = counts.get(event_name, 0) + 1
+ ts = str(event.get("ts", ""))
+ if ts:
+ last_event_time = ts
+ if event_name.endswith("_violation"):
+ latest_alert = ts
+
+ active_alert_count = sum(counts.get(name, 0) for name in counts if name.endswith("_violation"))
+ headline = "No batch events yet"
+ if events:
+ headline = f"{len(events)} event(s), {active_alert_count} violation event(s)"
+
+ return {
+ "result_type": PROJECT_TYPE,
+ "headline": headline,
+ "last_result_time": last_event_time,
+ "metrics": {
+ "event_counts": counts,
+ "event_count": len(events),
+ "violation_count": active_alert_count,
+ "latest_alert_time": latest_alert,
+ "events_path": str(event_sink_path(ctx)),
+ },
+ }
+
+
+def load_events(ctx: ManageContext, limit: int) -> list[dict[str, Any]]:
+ path = event_sink_path(ctx)
+ lines = tail_lines(path, limit)
+ events: list[dict[str, Any]] = []
+ for line in lines:
+ try:
+ payload = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(payload, dict):
+ events.append(payload)
+ return events
+
+
+def event_sink_path(ctx: ManageContext, data: dict[str, Any] | None = None) -> Path:
+ if data is None:
+ data = load_config_document(ctx.config_path)
+ raw_path = str(data.get("event_sink", {}).get("path", "logs/events.jsonl"))
+ path = Path(raw_path).expanduser()
+ if not path.is_absolute():
+ path = ctx.project_root / path
+ return path.resolve()
+
+
+def tail_lines(path: Path, limit: int) -> list[str]:
+ if not path.exists():
+ return []
+ lines = path.read_text(encoding="utf-8").splitlines()
+ return lines[-limit:]
+
+
+def capture_rtsp_frame(rtsp_url: str, timeout_seconds: float) -> bytes:
+ command = [
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-rtsp_transport",
+ "tcp",
+ "-i",
+ rtsp_url,
+ "-frames:v",
+ "1",
+ "-f",
+ "image2pipe",
+ "-vcodec",
+ "mjpeg",
+ "-",
+ ]
+ try:
+ result = subprocess.run(
+ command,
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ timeout=max(1.0, timeout_seconds),
+ )
+ except FileNotFoundError as exc:
+ raise CaptureError("ffmpeg not found; install ffmpeg first") from exc
+ except subprocess.TimeoutExpired as exc:
+ raise CaptureError(f"ffmpeg timed out after {timeout_seconds:g}s") from exc
+
+ if result.returncode != 0:
+ message = result.stderr.decode("utf-8", errors="replace").strip()
+ raise CaptureError(message or f"ffmpeg exited with code {result.returncode}")
+ if not result.stdout:
+ raise CaptureError("ffmpeg returned no image data")
+ return result.stdout
+
+
+def bounded_int(value: Any, minimum: int, maximum: int) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ parsed = minimum
+ return min(maximum, max(minimum, parsed))
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_manage_api.py b/tests/test_manage_api.py
new file mode 100644
index 0000000..82fd7b9
--- /dev/null
+++ b/tests/test_manage_api.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from cold_display_guard.config import load_config_document, merge_calibration, save_config_document
+from cold_display_guard.manage_api import ManageContext, build_summary
+
+
+class ManageApiTests(unittest.TestCase):
+ def test_merge_calibration_updates_zones_and_trash(self) -> None:
+ data = {
+ "camera_id": "cam",
+ "layout": {"rows": 2, "cols": 4, "zone_ids": ["r1c1"]},
+ "zones": [],
+ }
+
+ merged = merge_calibration(
+ data,
+ [{"id": "r1c1", "polygon": [[0, 0], [0.5, 0], [0.5, 0.5], [0, 0.5]]}],
+ [[0.8, 0.8], [1, 0.8], [1, 1], [0.8, 1]],
+ )
+
+ self.assertEqual(merged["layout"]["zone_ids"], ["r1c1"])
+ self.assertEqual(merged["zones"][0]["id"], "r1c1")
+ self.assertEqual(merged["trash"]["roi"][0], [0.8, 0.8])
+
+ def test_save_config_document_round_trips_manage_fields(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ path = Path(tmpdir) / "config.toml"
+ save_config_document(
+ path,
+ {
+ "camera_id": "cam",
+ "timezone": "Asia/Shanghai",
+ "stream": {"rtsp_url": "rtsp://example"},
+ "thresholds": {"max_dwell_seconds": 30, "trash_confirmation_seconds": 5},
+ "layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]},
+ "zones": [{"id": "r1c1", "polygon": [[0, 0], [1, 0], [1, 1]]}],
+ "trash": {"roi": [[0, 0], [1, 0], [1, 1]]},
+ "event_sink": {"path": "logs/events.jsonl"},
+ },
+ )
+ loaded = load_config_document(path)
+
+ self.assertEqual(loaded["stream"]["rtsp_url"], "rtsp://example")
+ self.assertEqual(loaded["zones"][0]["polygon"][1], [1.0, 0.0])
+
+ def test_summary_reads_event_jsonl(self) -> None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = Path(tmpdir)
+ config_path = root / "config" / "local.toml"
+ save_config_document(
+ config_path,
+ {
+ "event_sink": {"path": "logs/events.jsonl"},
+ "layout": {"rows": 1, "cols": 1, "zone_ids": ["r1c1"]},
+ },
+ )
+ events_path = root / "logs" / "events.jsonl"
+ events_path.parent.mkdir()
+ events_path.write_text(
+ "\n".join(
+ [
+ json.dumps({"event": "batch_started", "ts": "2026-04-27T10:00:00+08:00"}),
+ json.dumps({"event": "missing_disposal_violation", "ts": "2026-04-27T13:02:00+08:00"}),
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ summary = build_summary(ManageContext(config_path=config_path, project_root=root))
+
+ self.assertEqual(summary["metrics"]["event_count"], 2)
+ self.assertEqual(summary["metrics"]["violation_count"], 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..a62f59d
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ 冷藏展示柜管理
+
+
+
+
+
+
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..90da362
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "cold-display-guard-web",
+ "version": "0.1.0",
+ "type": "module",
+ "packageManager": "pnpm@10.30.3",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1 --port 23000",
+ "build": "vite build",
+ "preview": "vite preview --host 127.0.0.1 --port 23000"
+ },
+ "devDependencies": {
+ "vite": "^5.0.0"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..0396d7f
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,584 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ vite:
+ specifier: ^5.0.0
+ version: 5.4.21
+
+packages:
+
+ '@esbuild/aix-ppc64@0.21.5':
+ resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.21.5':
+ resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.21.5':
+ resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.21.5':
+ resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.21.5':
+ resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.21.5':
+ resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.21.5':
+ resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.21.5':
+ resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.21.5':
+ resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.21.5':
+ resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.21.5':
+ resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.21.5':
+ resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.21.5':
+ resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.21.5':
+ resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.21.5':
+ resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.21.5':
+ resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-x64@0.21.5':
+ resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-x64@0.21.5':
+ resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/sunos-x64@0.21.5':
+ resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.21.5':
+ resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.21.5':
+ resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.21.5':
+ resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ resolution: {integrity: sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ resolution: {integrity: sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ resolution: {integrity: sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ resolution: {integrity: sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ resolution: {integrity: sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ resolution: {integrity: sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ resolution: {integrity: sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ resolution: {integrity: sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ resolution: {integrity: sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ resolution: {integrity: sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ resolution: {integrity: sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/estree@1.0.8':
+ resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+
+ esbuild@0.21.5:
+ resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ nanoid@3.3.11:
+ resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ postcss@8.5.12:
+ resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.60.2:
+ resolution: {integrity: sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ vite@5.4.21:
+ resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || >=20.0.0
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
+snapshots:
+
+ '@esbuild/aix-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/android-arm@0.21.5':
+ optional: true
+
+ '@esbuild/android-x64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/darwin-x64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-arm@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/linux-loong64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.21.5':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.21.5':
+ optional: true
+
+ '@esbuild/linux-s390x@0.21.5':
+ optional: true
+
+ '@esbuild/linux-x64@0.21.5':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.21.5':
+ optional: true
+
+ '@esbuild/sunos-x64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-arm64@0.21.5':
+ optional: true
+
+ '@esbuild/win32-ia32@0.21.5':
+ optional: true
+
+ '@esbuild/win32-x64@0.21.5':
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.60.2':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.60.2':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.60.2':
+ optional: true
+
+ '@types/estree@1.0.8': {}
+
+ esbuild@0.21.5:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.21.5
+ '@esbuild/android-arm': 0.21.5
+ '@esbuild/android-arm64': 0.21.5
+ '@esbuild/android-x64': 0.21.5
+ '@esbuild/darwin-arm64': 0.21.5
+ '@esbuild/darwin-x64': 0.21.5
+ '@esbuild/freebsd-arm64': 0.21.5
+ '@esbuild/freebsd-x64': 0.21.5
+ '@esbuild/linux-arm': 0.21.5
+ '@esbuild/linux-arm64': 0.21.5
+ '@esbuild/linux-ia32': 0.21.5
+ '@esbuild/linux-loong64': 0.21.5
+ '@esbuild/linux-mips64el': 0.21.5
+ '@esbuild/linux-ppc64': 0.21.5
+ '@esbuild/linux-riscv64': 0.21.5
+ '@esbuild/linux-s390x': 0.21.5
+ '@esbuild/linux-x64': 0.21.5
+ '@esbuild/netbsd-x64': 0.21.5
+ '@esbuild/openbsd-x64': 0.21.5
+ '@esbuild/sunos-x64': 0.21.5
+ '@esbuild/win32-arm64': 0.21.5
+ '@esbuild/win32-ia32': 0.21.5
+ '@esbuild/win32-x64': 0.21.5
+
+ fsevents@2.3.3:
+ optional: true
+
+ nanoid@3.3.11: {}
+
+ picocolors@1.1.1: {}
+
+ postcss@8.5.12:
+ dependencies:
+ nanoid: 3.3.11
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.60.2:
+ dependencies:
+ '@types/estree': 1.0.8
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.60.2
+ '@rollup/rollup-android-arm64': 4.60.2
+ '@rollup/rollup-darwin-arm64': 4.60.2
+ '@rollup/rollup-darwin-x64': 4.60.2
+ '@rollup/rollup-freebsd-arm64': 4.60.2
+ '@rollup/rollup-freebsd-x64': 4.60.2
+ '@rollup/rollup-linux-arm-gnueabihf': 4.60.2
+ '@rollup/rollup-linux-arm-musleabihf': 4.60.2
+ '@rollup/rollup-linux-arm64-gnu': 4.60.2
+ '@rollup/rollup-linux-arm64-musl': 4.60.2
+ '@rollup/rollup-linux-loong64-gnu': 4.60.2
+ '@rollup/rollup-linux-loong64-musl': 4.60.2
+ '@rollup/rollup-linux-ppc64-gnu': 4.60.2
+ '@rollup/rollup-linux-ppc64-musl': 4.60.2
+ '@rollup/rollup-linux-riscv64-gnu': 4.60.2
+ '@rollup/rollup-linux-riscv64-musl': 4.60.2
+ '@rollup/rollup-linux-s390x-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-musl': 4.60.2
+ '@rollup/rollup-openbsd-x64': 4.60.2
+ '@rollup/rollup-openharmony-arm64': 4.60.2
+ '@rollup/rollup-win32-arm64-msvc': 4.60.2
+ '@rollup/rollup-win32-ia32-msvc': 4.60.2
+ '@rollup/rollup-win32-x64-gnu': 4.60.2
+ '@rollup/rollup-win32-x64-msvc': 4.60.2
+ fsevents: 2.3.3
+
+ source-map-js@1.2.1: {}
+
+ vite@5.4.21:
+ dependencies:
+ esbuild: 0.21.5
+ postcss: 8.5.12
+ rollup: 4.60.2
+ optionalDependencies:
+ fsevents: 2.3.3
diff --git a/web/src/main.js b/web/src/main.js
new file mode 100644
index 0000000..1eeb8dc
--- /dev/null
+++ b/web/src/main.js
@@ -0,0 +1,485 @@
+import "./styles.css";
+
+const zoneIds = ["r1c1", "r1c2", "r1c3", "r1c4", "r2c1", "r2c2", "r2c3", "r2c4"];
+const allRegions = [...zoneIds, "trash"];
+const palette = {
+ r1c1: "#d92d20",
+ r1c2: "#b54708",
+ r1c3: "#4e5ba6",
+ r1c4: "#008a5a",
+ r2c1: "#0077a3",
+ r2c2: "#155eef",
+ r2c3: "#7f56d9",
+ r2c4: "#c11574",
+ trash: "#111827",
+};
+
+const state = {
+ config: null,
+ summary: null,
+ events: [],
+ activeTab: "calibration",
+ activeRegion: "r1c1",
+ polygons: Object.fromEntries(allRegions.map((id) => [id, []])),
+ image: null,
+ imageUrl: null,
+ status: "正在连接后端...",
+};
+
+const app = document.querySelector("#app");
+
+app.innerHTML = `
+
+`;
+
+const els = {
+ statusText: document.querySelector("#statusText"),
+ canvas: document.querySelector("#canvas"),
+ regionList: document.querySelector("#regionList"),
+ rtspUrl: document.querySelector("#rtspUrl"),
+ cameraId: document.querySelector("#cameraId"),
+ timezone: document.querySelector("#timezone"),
+ maxDwell: document.querySelector("#maxDwell"),
+ trashWindow: document.querySelector("#trashWindow"),
+ configPreview: document.querySelector("#configPreview"),
+ regionSummary: document.querySelector("#regionSummary"),
+ metrics: document.querySelector("#metrics"),
+ eventsTable: document.querySelector("#eventsTable"),
+};
+const ctx = els.canvas.getContext("2d");
+
+function boot() {
+ wireEvents();
+ renderRegionList();
+ refreshAll();
+}
+
+function wireEvents() {
+ document.querySelectorAll(".tabs button").forEach((button) => {
+ button.addEventListener("click", () => setTab(button.dataset.tab));
+ });
+ document.querySelector("#refreshAll").addEventListener("click", refreshAll);
+ document.querySelector("#saveConfig").addEventListener("click", saveConfig);
+ document.querySelector("#captureSnapshot").addEventListener("click", captureSnapshot);
+ document.querySelector("#saveCalibration").addEventListener("click", saveCalibration);
+ document.querySelector("#undoPoint").addEventListener("click", undoPoint);
+ document.querySelector("#clearRegion").addEventListener("click", clearRegion);
+ document.querySelector("#loadConfigPolygons").addEventListener("click", loadPolygonsFromConfig);
+ els.canvas.addEventListener("click", addPoint);
+ window.addEventListener("resize", drawCanvas);
+}
+
+async function refreshAll() {
+ try {
+ setStatus("正在读取配置和事件...");
+ const [config, summary, events] = await Promise.all([
+ apiJson("/api/manage/config"),
+ apiJson("/api/manage/summary"),
+ apiJson("/api/manage/events?limit=200"),
+ ]);
+ state.config = config;
+ state.summary = summary;
+ state.events = events.items || [];
+ fillForm();
+ loadPolygonsFromConfig(false);
+ render();
+ setStatus("已连接后端 19080");
+ } catch (error) {
+ setStatus(`连接失败:${error.message}`);
+ }
+}
+
+async function saveConfig() {
+ try {
+ const payload = {
+ camera_id: els.cameraId.value.trim(),
+ timezone: els.timezone.value.trim(),
+ rtsp_url: els.rtspUrl.value.trim(),
+ thresholds: {
+ max_dwell_seconds: Number(els.maxDwell.value),
+ trash_confirmation_seconds: Number(els.trashWindow.value),
+ },
+ };
+ state.config = await apiJson("/api/manage/config", {method: "PUT", body: payload});
+ fillForm();
+ renderConfigPreview();
+ setStatus("配置已保存");
+ } catch (error) {
+ setStatus(`保存配置失败:${error.message}`);
+ }
+}
+
+async function captureSnapshot() {
+ try {
+ setStatus("正在从 RTSP 抓取一帧...");
+ const response = await fetch("/api/manage/snapshot", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({rtsp_url: els.rtspUrl.value.trim(), timeout_seconds: 12}),
+ });
+ if (!response.ok) {
+ const payload = await response.json();
+ throw new Error(payload.error || `HTTP ${response.status}`);
+ }
+ const blob = await response.blob();
+ if (state.imageUrl) {
+ URL.revokeObjectURL(state.imageUrl);
+ }
+ state.imageUrl = URL.createObjectURL(blob);
+ const image = new Image();
+ image.onload = () => {
+ state.image = image;
+ els.canvas.width = image.naturalWidth;
+ els.canvas.height = image.naturalHeight;
+ drawCanvas();
+ setStatus(`已抓取 ${image.naturalWidth}x${image.naturalHeight}`);
+ };
+ image.src = state.imageUrl;
+ } catch (error) {
+ setStatus(`抓帧失败:${error.message}`);
+ }
+}
+
+async function saveCalibration() {
+ try {
+ const zones = zoneIds
+ .map((id) => ({id, polygon: state.polygons[id]}))
+ .filter((zone) => zone.polygon.length >= 3);
+ const trashPolygon = state.polygons.trash;
+ if (zones.length !== zoneIds.length) {
+ setStatus("8 个格口都标定后才能保存");
+ return;
+ }
+ if (trashPolygon.length < 3) {
+ setStatus("垃圾桶区域至少需要 3 个点");
+ return;
+ }
+ state.config = await apiJson("/api/manage/calibration", {
+ method: "PUT",
+ body: {zones, trash: {roi: trashPolygon}},
+ });
+ render();
+ setStatus("标定已保存到项目配置");
+ } catch (error) {
+ setStatus(`保存标定失败:${error.message}`);
+ }
+}
+
+function setTab(tab) {
+ state.activeTab = tab;
+ document.querySelectorAll(".tabs button").forEach((button) => {
+ button.classList.toggle("active", button.dataset.tab === tab);
+ });
+ document.querySelector("#calibrationView").classList.toggle("hidden", tab !== "calibration");
+ document.querySelector("#eventsView").classList.toggle("hidden", tab !== "events");
+ document.querySelector("#settingsView").classList.toggle("hidden", tab !== "settings");
+}
+
+function fillForm() {
+ const config = state.config || {};
+ els.rtspUrl.value = config.stream?.rtsp_url || "";
+ els.cameraId.value = config.camera_id || "";
+ els.timezone.value = config.timezone || "";
+ els.maxDwell.value = config.thresholds?.max_dwell_seconds || 10800;
+ els.trashWindow.value = config.thresholds?.trash_confirmation_seconds || 120;
+}
+
+function loadPolygonsFromConfig(updateStatus = true) {
+ if (!state.config) {
+ return;
+ }
+ for (const zone of state.config.zones || []) {
+ if (zone.id && Array.isArray(zone.polygon)) {
+ state.polygons[zone.id] = zone.polygon.map(([x, y]) => ({x, y}));
+ }
+ }
+ if (Array.isArray(state.config.trash?.roi)) {
+ state.polygons.trash = state.config.trash.roi.map(([x, y]) => ({x, y}));
+ }
+ render();
+ if (updateStatus) {
+ setStatus("已载入当前配置区域");
+ }
+}
+
+function render() {
+ renderRegionList();
+ drawCanvas();
+ renderRegionSummary();
+ renderMetrics();
+ renderEvents();
+ renderConfigPreview();
+ setTab(state.activeTab);
+}
+
+function renderRegionList() {
+ els.regionList.innerHTML = "";
+ for (const id of allRegions) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = `${id}${state.polygons[id].length >= 3 ? " ✓" : ""}`;
+ button.className = id === state.activeRegion ? "active" : "";
+ button.addEventListener("click", () => {
+ state.activeRegion = id;
+ render();
+ });
+ els.regionList.appendChild(button);
+ }
+}
+
+function addPoint(event) {
+ if (!state.image) {
+ setStatus("请先从 RTSP 抓取一帧");
+ return;
+ }
+ const rect = els.canvas.getBoundingClientRect();
+ const x = clamp((event.clientX - rect.left) / rect.width);
+ const y = clamp((event.clientY - rect.top) / rect.height);
+ state.polygons[state.activeRegion].push({x: round(x), y: round(y)});
+ render();
+}
+
+function undoPoint() {
+ state.polygons[state.activeRegion].pop();
+ render();
+}
+
+function clearRegion() {
+ state.polygons[state.activeRegion] = [];
+ render();
+}
+
+function drawCanvas() {
+ ctx.clearRect(0, 0, els.canvas.width, els.canvas.height);
+ if (state.image) {
+ ctx.drawImage(state.image, 0, 0, els.canvas.width, els.canvas.height);
+ } else {
+ ctx.fillStyle = "#121826";
+ ctx.fillRect(0, 0, els.canvas.width, els.canvas.height);
+ ctx.fillStyle = "#dbe3ea";
+ ctx.font = "22px system-ui";
+ ctx.textAlign = "center";
+ ctx.fillText("输入 RTSP 地址并抓取一帧", els.canvas.width / 2, els.canvas.height / 2);
+ }
+ for (const id of allRegions) {
+ drawPolygon(id, state.polygons[id]);
+ }
+}
+
+function drawPolygon(id, points) {
+ if (!points.length) {
+ return;
+ }
+ const color = palette[id] || "#ffffff";
+ ctx.save();
+ ctx.strokeStyle = color;
+ ctx.fillStyle = color;
+ ctx.lineWidth = id === state.activeRegion ? 4 : 2;
+ ctx.beginPath();
+ points.forEach((point, index) => {
+ const x = point.x * els.canvas.width;
+ const y = point.y * els.canvas.height;
+ if (index === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ });
+ if (points.length >= 3) {
+ ctx.closePath();
+ ctx.globalAlpha = 0.22;
+ ctx.fill();
+ ctx.globalAlpha = 1;
+ }
+ ctx.stroke();
+ points.forEach((point, index) => {
+ const x = point.x * els.canvas.width;
+ const y = point.y * els.canvas.height;
+ ctx.beginPath();
+ ctx.arc(x, y, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.fillStyle = "#ffffff";
+ ctx.font = "12px system-ui";
+ ctx.textAlign = "center";
+ ctx.fillText(String(index + 1), x, y - 9);
+ ctx.fillStyle = color;
+ });
+ const first = points[0];
+ ctx.font = id === state.activeRegion ? "bold 18px system-ui" : "14px system-ui";
+ ctx.textAlign = "left";
+ ctx.fillText(id, first.x * els.canvas.width + 8, first.y * els.canvas.height + 18);
+ ctx.restore();
+}
+
+function renderRegionSummary() {
+ els.regionSummary.innerHTML = allRegions
+ .map((id) => {
+ const count = state.polygons[id].length;
+ return `${id}${count >= 3 ? `${count} 点,已标定` : `${count} 点`}
`;
+ })
+ .join("");
+}
+
+function renderMetrics() {
+ const metrics = state.summary?.metrics || {};
+ const cards = [
+ ["事件总数", metrics.event_count ?? 0],
+ ["违规事件", metrics.violation_count ?? 0],
+ ["最新报警", metrics.latest_alert_time || "-"],
+ ["事件文件", metrics.events_path || "-"],
+ ];
+ els.metrics.innerHTML = cards.map(([label, value]) => `${label}${value}
`).join("");
+}
+
+function renderEvents() {
+ if (!state.events.length) {
+ els.eventsTable.innerHTML = `还没有事件数据
`;
+ return;
+ }
+ els.eventsTable.innerHTML = `
+
+ | 时间 | 事件 | 区域 | 批次 | 停留秒数 |
+
+ ${state.events
+ .slice()
+ .reverse()
+ .map((event) => `
+
+ | ${escapeHtml(event.ts || "")} |
+ ${escapeHtml(event.event || "")} |
+ ${escapeHtml(event.zone_id || "")} |
+ ${escapeHtml(event.batch_id || "")} |
+ ${escapeHtml(String(event.dwell_seconds ?? ""))} |
+
+ `)
+ .join("")}
+
+
+ `;
+}
+
+function renderConfigPreview() {
+ els.configPreview.textContent = JSON.stringify(state.config || {}, null, 2);
+}
+
+async function apiJson(path, options = {}) {
+ const request = {...options};
+ if (request.body && typeof request.body !== "string") {
+ request.headers = {"Content-Type": "application/json", ...(request.headers || {})};
+ request.body = JSON.stringify(request.body);
+ }
+ const response = await fetch(path, request);
+ const payload = await response.json();
+ if (!response.ok) {
+ throw new Error(payload.error || `HTTP ${response.status}`);
+ }
+ return payload;
+}
+
+function setStatus(message) {
+ state.status = message;
+ els.statusText.textContent = message;
+}
+
+function clamp(value) {
+ return Math.min(1, Math.max(0, value));
+}
+
+function round(value) {
+ return Math.round(value * 1000000) / 1000000;
+}
+
+function escapeHtml(value) {
+ return value.replace(/[&<>"']/g, (char) => ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ })[char]);
+}
+
+boot();
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..51aae4a
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,302 @@
+:root {
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #1d2939;
+ background: #f3f5f7;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+button {
+ border: 1px solid #c8d0d8;
+ background: #ffffff;
+ color: #1d2939;
+ border-radius: 6px;
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+button:hover {
+ background: #eef3f7;
+}
+
+input {
+ width: 100%;
+ border: 1px solid #b6c0ca;
+ border-radius: 6px;
+ padding: 9px 10px;
+ background: #ffffff;
+}
+
+label {
+ display: grid;
+ gap: 6px;
+ color: #475467;
+ font-size: 13px;
+}
+
+.shell {
+ min-height: 100vh;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 14px 24px;
+ background: #ffffff;
+ border-bottom: 1px solid #d8dee5;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.brand-mark {
+ display: grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ background: #155eef;
+ color: #ffffff;
+ font-weight: 700;
+}
+
+.brand-title {
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.brand-subtitle {
+ margin-top: 2px;
+ color: #667085;
+ font-size: 13px;
+}
+
+.tabs {
+ 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 {
+ display: grid;
+ grid-template-columns: minmax(360px, 1fr) auto auto auto;
+ 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);
+}
+
+.panel,
+.canvas-panel,
+.config-preview {
+ background: #ffffff;
+ border: 1px solid #d8dee5;
+ border-radius: 8px;
+}
+
+.panel {
+ padding: 12px;
+}
+
+.panel-title {
+ margin-bottom: 10px;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.region-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.region-list button.active {
+ background: #155eef;
+ border-color: #155eef;
+ color: #ffffff;
+}
+
+.button-stack {
+ display: grid;
+ gap: 8px;
+ margin-top: 14px;
+}
+
+.canvas-panel {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ background: #121826;
+}
+
+canvas {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ cursor: crosshair;
+}
+
+.region-summary {
+ display: grid;
+ gap: 8px;
+}
+
+.region-summary div {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding-bottom: 7px;
+ border-bottom: 1px solid #edf1f5;
+}
+
+.region-summary span {
+ color: #667085;
+}
+
+.metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.metric {
+ display: grid;
+ gap: 8px;
+ padding: 14px;
+ background: #ffffff;
+ border: 1px solid #d8dee5;
+ border-radius: 8px;
+}
+
+.metric span {
+ color: #667085;
+ font-size: 13px;
+}
+
+.metric strong {
+ overflow-wrap: anywhere;
+ font-size: 18px;
+}
+
+.events-table {
+ overflow: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 10px;
+ border-bottom: 1px solid #edf1f5;
+ text-align: left;
+ white-space: nowrap;
+}
+
+th {
+ color: #475467;
+ font-size: 13px;
+}
+
+.settings-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(240px, 1fr));
+ gap: 12px;
+ margin-bottom: 12px;
+ padding: 12px;
+ background: #ffffff;
+ border: 1px solid #d8dee5;
+ border-radius: 8px;
+}
+
+.config-preview {
+ margin: 0;
+ padding: 12px;
+ min-height: 320px;
+ overflow: auto;
+ font-size: 12px;
+}
+
+.empty {
+ padding: 24px;
+ color: #667085;
+}
+
+.hidden {
+ display: none;
+}
+
+@media (max-width: 1100px) {
+ .header,
+ .toolbar {
+ grid-template-columns: 1fr;
+ }
+
+ .header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .tabs {
+ flex-wrap: wrap;
+ }
+
+ .calibration-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .metrics,
+ .settings-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/web/vite.config.js b/web/vite.config.js
new file mode 100644
index 0000000..fbef2f6
--- /dev/null
+++ b/web/vite.config.js
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ server: {
+ host: "127.0.0.1",
+ port: 23000,
+ proxy: {
+ "/api": {
+ target: "http://127.0.0.1:19080",
+ changeOrigin: true,
+ },
+ },
+ },
+});