feat: add management web console
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,3 +7,5 @@ build/
|
||||
*.egg-info/
|
||||
logs/
|
||||
*.jsonl
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
43
README_zh.md
43
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
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
8
scripts/run_manage_api.sh
Executable file
8
scripts/run_manage_api.sh
Executable 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
5
scripts/run_web.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd web
|
||||
pnpm dev --host 127.0.0.1 --port 23000
|
||||
@@ -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('"', '\\"')
|
||||
|
||||
351
src/cold_display_guard/manage_api.py
Normal file
351
src/cold_display_guard/manage_api.py
Normal 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
81
tests/test_manage_api.py
Normal 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
12
web/index.html
Normal 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
14
web/package.json
Normal 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
584
web/pnpm-lock.yaml
generated
Normal 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
485
web/src/main.js
Normal 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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
boot();
|
||||
302
web/src/styles.css
Normal file
302
web/src/styles.css
Normal 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
14
web/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user