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 = ` +
+
+
+
CD
+
+
冷藏展示柜管理
+
标定、配置、事件数据
+
+
+ +
+ +
+
+ + +
+ +
+
+ + + + +
+ +
+ +
+ +
+ +
+
+ + + + +
+
+`; + +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) => ` + + + + + + + + `) + .join("")} + +
时间事件区域批次停留秒数
${escapeHtml(event.ts || "")}${escapeHtml(event.event || "")}${escapeHtml(event.zone_id || "")}${escapeHtml(event.batch_id || "")}${escapeHtml(String(event.dwell_seconds ?? ""))}
+ `; +} + +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, + }, + }, + }, +});