feat: add management web console

This commit is contained in:
Yoilun
2026-04-27 11:28:57 +08:00
parent b3672c564a
commit c4f9dab049
17 changed files with 2051 additions and 7 deletions

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@ build/
*.egg-info/ *.egg-info/
logs/ logs/
*.jsonl *.jsonl
web/node_modules/
web/dist/

View File

@@ -51,22 +51,55 @@
## 区域标定 ## 区域标定
项目内置一个本地 Web 标定工具,可以从 RTSP 拉取一帧截图,再用鼠标标定 8 个格口和垃圾桶区域: 项目现在有正式管理页,前端默认 `23000`,后端默认 `19080`
```bash ```bash
python3 tools/calibrator/server.py --host 127.0.0.1 --port 18090 scripts/run_manage_api.sh
```
另开一个终端:
```bash
scripts/run_web.sh
``` ```
打开: 打开:
```text ```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 ```bash
python3 -m unittest discover -s tests -v PYTHONPATH=src python3 -m unittest discover -s tests -v
``` ```

View File

@@ -1,6 +1,9 @@
camera_id = "cold_display_cam_01" camera_id = "cold_display_cam_01"
timezone = "Asia/Shanghai" timezone = "Asia/Shanghai"
[stream]
rtsp_url = ""
[thresholds] [thresholds]
max_dwell_seconds = 10800 max_dwell_seconds = 10800
trash_confirmation_seconds = 120 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] [trash]
roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]] roi = [[0.80, 0.65], [1.00, 0.65], [1.00, 1.00], [0.80, 1.00]]
[event_sink]
path = "logs/events.jsonl"

View File

@@ -114,7 +114,20 @@ Trash disposal confirmation should use motion/object evidence inside the trash R
## Calibration Tool ## 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`. 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`.

View File

@@ -10,3 +10,4 @@
- Test suite now passes: `PYTHONPATH=src python3 -m unittest discover -s tests -v`. - Test suite now passes: `PYTHONPATH=src python3 -m unittest discover -s tests -v`.
- Initialized git repository and created the initial project commit. - Initialized git repository and created the initial project commit.
- Added RTSP single-frame calibration tool under `tools/calibrator`. - Added RTSP single-frame calibration tool under `tools/calibrator`.
- Added formal management API on port `19080` and Vite frontend on port `23000`.

View File

@@ -12,6 +12,7 @@ dependencies = []
[project.scripts] [project.scripts]
cold-display-guard = "cold_display_guard.cli:main" cold-display-guard = "cold_display_guard.cli:main"
cold-display-guard-manage = "cold_display_guard.manage_api:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

8
scripts/run_manage_api.sh Executable file
View File

@@ -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"

5
scripts/run_web.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
cd web
pnpm dev --host 127.0.0.1 --port 23000

View File

@@ -1,14 +1,18 @@
from __future__ import annotations from __future__ import annotations
import tomllib import tomllib
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings from cold_display_guard.models import DEFAULT_ZONE_IDS, EngineSettings
DEFAULT_CONFIG_PATH = Path("config/example.toml")
def load_settings(path: str | Path) -> EngineSettings: 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", {}) thresholds: dict[str, Any] = data.get("thresholds", {})
layout: dict[str, Any] = data.get("layout", {}) 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, ...]: def _zone_ids_from_rows_cols(layout: dict[str, Any]) -> tuple[str, ...]:
rows = int(layout.get("rows", 0)) rows = int(layout.get("rows", 0))
cols = int(layout.get("cols", 0)) cols = int(layout.get("cols", 0))
if rows <= 0 or cols <= 0: if rows <= 0 or cols <= 0:
return () return ()
return tuple(f"r{row}c{col}" for row in range(1, rows + 1) for col in range(1, cols + 1)) 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('"', '\\"')

View File

@@ -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())

81
tests/test_manage_api.py Normal file
View File

@@ -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()

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>冷藏展示柜管理</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

14
web/package.json Normal file
View File

@@ -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"
}
}

584
web/pnpm-lock.yaml generated Normal file
View File

@@ -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

485
web/src/main.js Normal file
View File

@@ -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 = `
<div class="shell">
<header class="header">
<div class="brand">
<div class="brand-mark">CD</div>
<div>
<div class="brand-title">冷藏展示柜管理</div>
<div class="brand-subtitle">标定、配置、事件数据</div>
</div>
</div>
<nav class="tabs">
<button data-tab="calibration">区域标定</button>
<button data-tab="events">事件数据</button>
<button data-tab="settings">运行配置</button>
</nav>
</header>
<main class="main">
<section class="status-line">
<span id="statusText"></span>
<button id="refreshAll" type="button">刷新</button>
</section>
<section id="calibrationView" class="view">
<section class="toolbar">
<label>
RTSP 地址
<input id="rtspUrl" type="text" placeholder="rtsp://user:password@camera-ip:554/stream">
</label>
<button id="saveConfig" type="button">保存配置</button>
<button id="captureSnapshot" type="button">抓取一帧</button>
<button id="saveCalibration" type="button">保存标定到配置</button>
</section>
<section class="calibration-grid">
<aside class="panel">
<div class="panel-title">区域</div>
<div id="regionList" class="region-list"></div>
<div class="button-stack">
<button id="undoPoint" type="button">撤销点</button>
<button id="clearRegion" type="button">清空当前区域</button>
<button id="loadConfigPolygons" type="button">载入当前配置区域</button>
</div>
</aside>
<section class="canvas-panel">
<canvas id="canvas" width="1280" height="720"></canvas>
</section>
<aside class="panel">
<div class="panel-title">标定结果</div>
<div id="regionSummary" class="region-summary"></div>
</aside>
</section>
</section>
<section id="eventsView" class="view hidden">
<section class="metrics" id="metrics"></section>
<section class="panel">
<div class="panel-title">最近事件</div>
<div id="eventsTable" class="events-table"></div>
</section>
</section>
<section id="settingsView" class="view hidden">
<section class="settings-grid">
<label>
Camera ID
<input id="cameraId" type="text">
</label>
<label>
时区
<input id="timezone" type="text">
</label>
<label>
最大放置秒数
<input id="maxDwell" type="number" min="1">
</label>
<label>
垃圾桶确认秒数
<input id="trashWindow" type="number" min="1">
</label>
</section>
<pre id="configPreview" class="config-preview"></pre>
</section>
</main>
</div>
`;
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 `<div><strong>${id}</strong><span>${count >= 3 ? `${count} 点,已标定` : `${count}`}</span></div>`;
})
.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]) => `<div class="metric"><span>${label}</span><strong>${value}</strong></div>`).join("");
}
function renderEvents() {
if (!state.events.length) {
els.eventsTable.innerHTML = `<div class="empty">还没有事件数据</div>`;
return;
}
els.eventsTable.innerHTML = `
<table>
<thead><tr><th>时间</th><th>事件</th><th>区域</th><th>批次</th><th>停留秒数</th></tr></thead>
<tbody>
${state.events
.slice()
.reverse()
.map((event) => `
<tr>
<td>${escapeHtml(event.ts || "")}</td>
<td>${escapeHtml(event.event || "")}</td>
<td>${escapeHtml(event.zone_id || "")}</td>
<td>${escapeHtml(event.batch_id || "")}</td>
<td>${escapeHtml(String(event.dwell_seconds ?? ""))}</td>
</tr>
`)
.join("")}
</tbody>
</table>
`;
}
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
boot();

302
web/src/styles.css Normal file
View File

@@ -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;
}
}

14
web/vite.config.js Normal file
View File

@@ -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,
},
},
},
});