feat: SQLite persistence, non-blocking inference, modular frontend

Backend:
- Persist alert events and robot delivery counters in SQLite (data/app.db,
  YOLO_DB_PATH override); EventStore keeps its interface, DeliveryStatsStore
  uses read/write-through UPSERT; in-memory fallback remains for standalone
  AlertManager use
- Serve /api/detect from a sync endpoint and serialize YOLO inference with a
  lock, so concurrent requests no longer block the event loop
- Unify single-image and session detection: requests without session_id
  share the fixed single-image session and run full frame confirmation
- Store UTC Z-suffixed timestamps for sortable string comparison; close
  executor and database on shutdown via FastAPI lifespan

Frontend:
- Split app.js into ES modules (main.js + modules/{dom,api,ui,charts,store}
  + views/{dashboard,inspection,events,robots,robotCards}), no build step
- Escape all server data interpolated into innerHTML; guard missing
  event.classes; replace lazy element ID list with memoized qs()
- Remove hardcoded fake stats (trend badge, device donut segment)

Docs:
- Rewrite README: accurate weight policy (only trained best.pt committed,
  no Git LFS), SQLite persistence, single-worker note, data asset inventory
- Align models/pretrained/README.md with actual files; add YOLO_DB_PATH to
  .env.example; add persistence unit tests; ruff clean
This commit is contained in:
2026-08-13 16:50:23 +08:00
parent 4dee7f664d
commit 25fa0c5825
25 changed files with 1058 additions and 669 deletions

View File

@@ -4,6 +4,9 @@ YOLO_IMGSZ=768
YOLO_CONF=0.40 YOLO_CONF=0.40
YOLO_IOU=0.45 YOLO_IOU=0.45
# SQLite 数据库路径(事件与机器人投递统计持久化)。
YOLO_DB_PATH=data/app.db
# 企业微信群机器人,可留空。 # 企业微信群机器人,可留空。
WECHAT_WEBHOOK_URL= WECHAT_WEBHOOK_URL=

View File

@@ -1,18 +1,20 @@
# Smoke and Fire YOLO # Smoke and Fire YOLO
Ultralytics YOLO training, validation, prediction, and export pipeline for the Smoke-Fire-Detection-YOLO dataset. Ultralytics YOLO training, validation, prediction, and export pipeline for the Smoke-Fire-Detection-YOLO dataset, plus an integrated fire prevention management platform (FastAPI + browser frontend).
## Project layout ## Project layout
```text ```text
configs/datasets/smoke_fire.yaml Dataset configuration configs/datasets/smoke_fire.yaml Dataset configuration
models/pretrained/yolo11s.pt Default pretrained model models/pretrained/ Locally cached base weights (not committed)
src/yolo/cli.py Command-line entry point src/yolo/cli.py Command-line entry point
src/yolo/config.py Training configuration src/yolo/config.py Training configuration
src/yolo/defaults.py Project defaults src/yolo/defaults.py Project defaults
src/yolo/engine.py Train, validate, predict, and export src/yolo/engine.py Train, validate, predict, and export
src/yolo/checkpoints.py Checkpoint discovery and resume support src/yolo/checkpoints.py Checkpoint discovery and resume support
src/yolo/reporting.py Training result reporting src/yolo/reporting.py Training result reporting
backend/ FastAPI inference API and alerting
frontend/ Browser management platform (no build step)
``` ```
## Setup ## Setup
@@ -21,6 +23,8 @@ src/yolo/reporting.py Training result reporting
uv sync uv sync
``` ```
Trained deployment weights are committed as regular files under `runs/detect/*/weights/best.pt`, so the API works right after cloning. Checkpoint files (`epoch*.pt`, `last.pt`) and re-downloadable base weights are excluded by `.gitignore`.
## Commands ## Commands
Fine-tune from the current best YOLO11s checkpoint: Fine-tune from the current best YOLO11s checkpoint:
@@ -69,6 +73,9 @@ uv run fire-yolo export --weights runs/detect/<run-name>/weights/best.pt --forma
- Output: `runs/detect` - Output: `runs/detect`
Training writes `last.pt`, `best.pt`, periodic checkpoints, and `best_point.json` to the run directory. Training writes `last.pt`, `best.pt`, periodic checkpoints, and `best_point.json` to the run directory.
The committed best weights and evaluation evidence are summarized in [`docs/model_comparison.md`](docs/model_comparison.md). Only trained `best.pt` files are committed; to make a newly trained model available for deployment, commit its `weights/best.pt` after the run finishes.
## Web Detection Service ## Web Detection Service
After training finishes, install the web dependencies and run the integrated frontend and inference API: After training finishes, install the web dependencies and run the integrated frontend and inference API:
@@ -80,6 +87,16 @@ uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000
Open `http://127.0.0.1:8000` to use the fire prevention management platform. It provides a system overview, local-video inspection, alert event handling, a risk register, robot channel status, and effective model settings. The browser plays selected videos locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap. Open `http://127.0.0.1:8000` to use the fire prevention management platform. It provides a system overview, local-video inspection, alert event handling, a risk register, robot channel status, and effective model settings. The browser plays selected videos locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap.
Management endpoints include `GET /api/dashboard`, `GET /api/events`, `PATCH /api/events/{event_id}`, and `GET /api/robots`. Alert events can be marked as pending, acknowledged, or resolved. The robot endpoint reports Enterprise WeChat and Feishu configuration and delivery statistics without exposing webhook credentials. Use `POST /api/robots/{channel}/test` to send a connection test to a configured group robot. The current implementation retains the most recent 500 events and robot delivery counters in process memory, so they are cleared when the API service restarts. Management endpoints include `GET /api/dashboard`, `GET /api/events`, `PATCH /api/events/{event_id}`, and `GET /api/robots`. Alert events can be marked as pending, acknowledged, or resolved. The robot endpoint reports Enterprise WeChat and Feishu configuration and delivery statistics without exposing webhook credentials. Use `POST /api/robots/{channel}/test` to send a connection test to a configured group robot.
Events and robot delivery counters are persisted in SQLite (`data/app.db` by default, override with `YOLO_DB_PATH`), so they survive service restarts. The event store keeps the most recent 500 events. Run the API with a single uvicorn worker: per-session consecutive-frame state lives in the process.
The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled. The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled.
Detection requests without a `session_id` share the fixed `single-image` session, so the consecutive-frame confirmation also applies to them; reset it with `DELETE /api/sessions/single-image`.
## Data assets
- `data/Smoke-Fire-Detection-YOLO/` — main smoke/fire dataset (train/val/test).
- `data/fire-dataset/` — incremental hard-negative collection (395 train / 87 val images, Pascal VOC XML plus YOLO labels); not yet wired to a training config.
- `runs/audit/` — label audit tooling: `scan_missing_labels.py` finds unlabeled images that the current model detects, `render_missing_label_candidates.py` renders them for review.

View File

@@ -11,7 +11,6 @@ import time
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO from io import BytesIO
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
@@ -19,6 +18,8 @@ from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from .storage import DeliveryStatsStore, utc_now_iso
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
ALERT_CLASSES = ("fire", "smoke") ALERT_CLASSES = ("fire", "smoke")
CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"} CHANNEL_NAMES = {"wechat": "企业微信", "feishu": "飞书"}
@@ -45,6 +46,7 @@ class AlertManager:
confirm_frames: int = 3, confirm_frames: int = 3,
cooldown_seconds: float = 60.0, cooldown_seconds: float = 60.0,
session_ttl_seconds: float = 3600.0, session_ttl_seconds: float = 3600.0,
stats_store: DeliveryStatsStore | None = None,
) -> None: ) -> None:
self.wechat_webhook_url = ( self.wechat_webhook_url = (
wechat_webhook_url wechat_webhook_url
@@ -58,6 +60,7 @@ class AlertManager:
self.confirm_frames = max(1, confirm_frames) self.confirm_frames = max(1, confirm_frames)
self.cooldown_seconds = max(0.0, cooldown_seconds) self.cooldown_seconds = max(0.0, cooldown_seconds)
self.session_ttl_seconds = max(60.0, session_ttl_seconds) self.session_ttl_seconds = max(60.0, session_ttl_seconds)
self.stats_store = stats_store
self._states: dict[str, SessionState] = {} self._states: dict[str, SessionState] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._delivery_stats = { self._delivery_stats = {
@@ -92,11 +95,27 @@ class AlertManager:
@property @property
def channel_status(self) -> list[dict[str, Any]]: def channel_status(self) -> list[dict[str, Any]]:
configured = self.channels configured = self.channels
with self._lock: if self.stats_store is not None:
saved = self.stats_store.get_all()
stats = { stats = {
channel: dict(values) channel: {
for channel, values in self._delivery_stats.items() "delivered": 0,
"failed": 0,
"tests": 0,
"last_status": "idle",
"last_delivery_at": None,
"last_test_at": None,
"last_error": None,
**saved.get(channel, {}),
}
for channel in CHANNEL_NAMES
} }
else:
with self._lock:
stats = {
channel: dict(values)
for channel, values in self._delivery_stats.items()
}
return [ return [
{ {
"id": channel, "id": channel,
@@ -191,6 +210,10 @@ class AlertManager:
with self._lock: with self._lock:
self._states.pop(session_id, None) self._states.pop(session_id, None)
def close(self) -> None:
"""Shut down the alert dispatch executor (call on service shutdown)."""
self._executor.shutdown(wait=True, cancel_futures=True)
def _prune_sessions(self, now: float) -> None: def _prune_sessions(self, now: float) -> None:
expired = [ expired = [
session_id session_id
@@ -230,7 +253,15 @@ class AlertManager:
error: str | None = None, error: str | None = None,
test: bool = False, test: bool = False,
) -> None: ) -> None:
now = datetime.now().astimezone().isoformat(timespec="seconds") if self.stats_store is not None:
self.stats_store.record(
channel,
success=success,
error=error,
test=test,
)
return
now = utc_now_iso()
with self._lock: with self._lock:
stats = self._delivery_stats[channel] stats = self._delivery_stats[channel]
stats["last_status"] = "success" if success else "failed" stats["last_status"] = "success" if success else "failed"
@@ -386,7 +417,7 @@ def alert_summary(
def feishu_signature(timestamp: str, secret: str) -> str: def feishu_signature(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8") string_to_sign = f"{timestamp}\n{secret}".encode()
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest() digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii") return base64.b64encode(digest).decode("ascii")
@@ -429,7 +460,7 @@ def annotate_image(
class_name = str(detection.get("class", "target")) class_name = str(detection.get("class", "target"))
confidence = float(detection.get("confidence", 0.0)) confidence = float(detection.get("confidence", 0.0))
color = colors.get(class_name, "#ffd166") color = colors.get(class_name, "#ffd166")
coordinates = tuple(int(round(value)) for value in box) coordinates = tuple(round(value) for value in box)
draw.rectangle(coordinates, outline=color, width=4) draw.rectangle(coordinates, outline=color, width=4)
draw.text( draw.text(
(coordinates[0] + 4, max(0, coordinates[1] - 16)), (coordinates[0] + 4, max(0, coordinates[1] - 16)),

View File

@@ -1,18 +1,44 @@
from __future__ import annotations from __future__ import annotations
import threading import json
import uuid import uuid
from collections import deque from datetime import UTC, datetime
from datetime import datetime
from typing import Any from typing import Any
from .storage import Database, utc_now_iso
EVENT_STATUSES = {"pending", "acknowledged", "resolved"} EVENT_STATUSES = {"pending", "acknowledged", "resolved"}
def _local_today_utc_boundary() -> str:
"""UTC Z-format timestamp of local midnight, for 'today' comparisons."""
local_midnight = datetime.now().astimezone().replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
return (
local_midnight.astimezone(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class EventStore: class EventStore:
def __init__(self, max_events: int = 500) -> None: """Persistent alert event store backed by a single SQLite table.
self._events: deque[dict[str, Any]] = deque(maxlen=max_events)
self._lock = threading.Lock() Keeps at most ``max_events`` rows (newest first, ordered by rowid so
events created within the same second keep a stable insertion order).
"""
def __init__(
self,
db: Database | None = None,
max_events: int = 500,
) -> None:
self._db = db or Database(":memory:")
self._max_events = max(1, max_events)
def create( def create(
self, self,
@@ -21,7 +47,6 @@ class EventStore:
detections: list[dict[str, Any]], detections: list[dict[str, Any]],
notification_channels: dict[str, bool], notification_channels: dict[str, bool],
) -> dict[str, Any]: ) -> dict[str, Any]:
now = datetime.now().astimezone()
confidences = [ confidences = [
float(detection.get("confidence", 0.0)) float(detection.get("confidence", 0.0))
for detection in detections for detection in detections
@@ -29,7 +54,7 @@ class EventStore:
] ]
event = { event = {
"id": uuid.uuid4().hex[:12], "id": uuid.uuid4().hex[:12],
"created_at": now.isoformat(timespec="seconds"), "created_at": utc_now_iso(),
"session_id": session_id, "session_id": session_id,
"classes": classes, "classes": classes,
"max_confidence": round(max(confidences, default=0.0), 6), "max_confidence": round(max(confidences, default=0.0), 6),
@@ -38,17 +63,44 @@ class EventStore:
"notification_channels": notification_channels, "notification_channels": notification_channels,
"handled_at": None, "handled_at": None,
} }
with self._lock: self._db.execute(
self._events.appendleft(event) """
return dict(event) INSERT INTO events (
id, created_at, session_id, classes, max_confidence,
detection_count, status, notification_channels, handled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event["id"],
event["created_at"],
session_id,
json.dumps(classes, ensure_ascii=False),
event["max_confidence"],
event["detection_count"],
event["status"],
json.dumps(notification_channels, ensure_ascii=False),
None,
),
)
self._db.execute(
"""
DELETE FROM events WHERE id IN (
SELECT id FROM events ORDER BY rowid DESC LIMIT -1 OFFSET ?
)
""",
(self._max_events,),
)
return event
def list( def list(
self, self,
limit: int = 100, limit: int = 100,
status: str | None = None, status: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
with self._lock: rows = self._db.execute(
events = [dict(event) for event in self._events] "SELECT * FROM events ORDER BY rowid DESC",
).fetchall()
events = [self._to_dict(row) for row in rows]
if status: if status:
events = [event for event in events if event["status"] == status] events = [event for event in events if event["status"] == status]
return events[: max(1, min(limit, 500))] return events[: max(1, min(limit, 500))]
@@ -56,42 +108,57 @@ class EventStore:
def update(self, event_id: str, status: str) -> dict[str, Any] | None: def update(self, event_id: str, status: str) -> dict[str, Any] | None:
if status not in EVENT_STATUSES: if status not in EVENT_STATUSES:
raise ValueError(f"Unsupported event status: {status}") raise ValueError(f"Unsupported event status: {status}")
with self._lock: cursor = self._db.execute(
for event in self._events: """
if event["id"] != event_id: UPDATE events SET status = ?, handled_at = ?
continue WHERE id = ?
event["status"] = status """,
event["handled_at"] = ( (status, None if status == "pending" else utc_now_iso(), event_id),
None )
if status == "pending" if cursor.rowcount == 0:
else datetime.now().astimezone().isoformat( return None
timespec="seconds" row = self._db.execute(
) "SELECT * FROM events WHERE id = ?",
) (event_id,),
return dict(event) ).fetchone()
return None return self._to_dict(row)
def summary(self) -> dict[str, int]: def summary(self) -> dict[str, int]:
today = datetime.now().astimezone().date() rows = self._db.execute(
with self._lock: "SELECT created_at, classes, status FROM events",
events = [dict(event) for event in self._events] ).fetchall()
today_events = [ today_boundary = _local_today_utc_boundary()
event today_events = 0
for event in events counts = {status: 0 for status in EVENT_STATUSES}
if datetime.fromisoformat(event["created_at"]).date() == today fire = 0
] smoke = 0
for row in rows:
counts[row["status"]] += 1
classes = json.loads(row["classes"])
if row["created_at"] >= today_boundary:
today_events += 1
fire += "fire" in classes
smoke += "smoke" in classes
return { return {
"total": len(events), "total": len(rows),
"today": len(today_events), "today": today_events,
"pending": sum( "pending": counts["pending"],
event["status"] == "pending" for event in events "acknowledged": counts["acknowledged"],
), "resolved": counts["resolved"],
"acknowledged": sum( "fire": fire,
event["status"] == "acknowledged" for event in events "smoke": smoke,
), }
"resolved": sum(
event["status"] == "resolved" for event in events @staticmethod
), def _to_dict(row: Any) -> dict[str, Any]:
"fire": sum("fire" in event["classes"] for event in events), return {
"smoke": sum("smoke" in event["classes"] for event in events), "id": row["id"],
"created_at": row["created_at"],
"session_id": row["session_id"],
"classes": json.loads(row["classes"]),
"max_confidence": row["max_confidence"],
"detection_count": row["detection_count"],
"status": row["status"],
"notification_channels": json.loads(row["notification_channels"]),
"handled_at": row["handled_at"],
} }

View File

@@ -2,21 +2,25 @@ from __future__ import annotations
import io import io
import os import os
import threading
import time import time
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from dotenv import load_dotenv
from fastapi import FastAPI, File, HTTPException, UploadFile from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from dotenv import load_dotenv
from pydantic import BaseModel from pydantic import BaseModel
from ultralytics import YOLO from ultralytics import YOLO
from .alerting import AlertManager from .alerting import AlertManager
from .events import EVENT_STATUSES, EventStore from .events import EVENT_STATUSES, EventStore
from .storage import Database, DeliveryStatsStore
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
FRONTEND_DIR = PROJECT_ROOT / "frontend" FRONTEND_DIR = PROJECT_ROOT / "frontend"
@@ -30,17 +34,37 @@ IOU = float(os.getenv("YOLO_IOU", "0.45"))
MAX_UPLOAD_BYTES = 15 * 1024 * 1024 MAX_UPLOAD_BYTES = 15 * 1024 * 1024
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000")) MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
CLASS_NAMES = {0: "smoke", 1: "fire"} CLASS_NAMES = {0: "smoke", 1: "fire"}
DB_PATH = Path(
os.getenv("YOLO_DB_PATH", str(PROJECT_ROOT / "data" / "app.db"))
).expanduser().resolve()
DB = Database(DB_PATH)
EVENT_STORE = EventStore(db=DB)
ALERT_MANAGER = AlertManager( ALERT_MANAGER = AlertManager(
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")), confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")), cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
stats_store=DeliveryStatsStore(DB),
) )
EVENT_STORE = EventStore() # Ultralytics models are not thread-safe: serialize inference so concurrent
# /api/detect requests from the FastAPI threadpool cannot race each other.
INFERENCE_LOCK = threading.Lock()
class EventStatusUpdate(BaseModel): class EventStatusUpdate(BaseModel):
status: str status: str
app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
yield
ALERT_MANAGER.close()
DB.close()
app = FastAPI(
title="Smoke Fire Detector API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@@ -77,14 +101,15 @@ def validate_image(payload: bytes) -> Image.Image:
def predict_image(image: Image.Image) -> dict[str, Any]: def predict_image(image: Image.Image) -> dict[str, Any]:
started_at = time.perf_counter() started_at = time.perf_counter()
try: try:
result = get_model().predict( with INFERENCE_LOCK:
source=image, result = get_model().predict(
conf=CONFIDENCE, source=image,
iou=IOU, conf=CONFIDENCE,
imgsz=IMAGE_SIZE, iou=IOU,
device=DEVICE, imgsz=IMAGE_SIZE,
verbose=False, device=DEVICE,
)[0] verbose=False,
)[0]
except FileNotFoundError as error: except FileNotFoundError as error:
raise HTTPException(status_code=503, detail=str(error)) from error raise HTTPException(status_code=503, detail=str(error)) from error
except Exception as error: except Exception as error:
@@ -118,30 +143,25 @@ def health() -> dict[str, Any]:
@app.post("/api/detect") @app.post("/api/detect")
async def detect( def detect(
file: UploadFile = File(...), file: UploadFile = File(...), # noqa: B008 — FastAPI dependency idiom
session_id: str | None = None, session_id: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = await file.read(MAX_UPLOAD_BYTES + 1) payload = file.file.read(MAX_UPLOAD_BYTES + 1)
image = validate_image(payload) image = validate_image(payload)
result = predict_image(image) result = predict_image(image)
result["alert"] = ( # Requests without a session share the fixed "single-image" session, so
ALERT_MANAGER.evaluate( # the consecutive-frame confirmation works there as well. Reset it with
session_id, # DELETE /api/sessions/single-image.
result["detections"], active_session = session_id or "single-image"
image, result["alert"] = ALERT_MANAGER.evaluate(
) active_session,
if session_id result["detections"],
else { image,
"triggered": False,
"classes": [],
"notification_enabled": ALERT_MANAGER.enabled,
"notification_channels": ALERT_MANAGER.channels,
}
) )
if result["alert"]["triggered"]: if result["alert"]["triggered"]:
result["event"] = EVENT_STORE.create( result["event"] = EVENT_STORE.create(
session_id=session_id or "single-image", session_id=active_session,
classes=result["alert"]["classes"], classes=result["alert"]["classes"],
detections=result["detections"], detections=result["detections"],
notification_channels=result["alert"].get( notification_channels=result["alert"].get(

137
backend/storage.py Normal file
View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import sqlite3
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
session_id TEXT NOT NULL,
classes TEXT NOT NULL,
max_confidence REAL NOT NULL,
detection_count INTEGER NOT NULL,
status TEXT NOT NULL,
notification_channels TEXT NOT NULL,
handled_at TEXT
);
CREATE TABLE IF NOT EXISTS delivery_stats (
channel TEXT PRIMARY KEY,
delivered INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
tests INTEGER NOT NULL DEFAULT 0,
last_status TEXT NOT NULL DEFAULT 'idle',
last_delivery_at TEXT,
last_test_at TEXT,
last_error TEXT
);
"""
def utc_now_iso() -> str:
"""Current UTC time as a sortable ISO string with a Z suffix."""
return (
datetime.now(UTC)
.isoformat(timespec="seconds")
.replace("+00:00", "Z")
)
class Database:
"""Single shared SQLite connection guarded by a lock.
The API service is low-write and multi-threaded (FastAPI threadpool,
alert dispatch executor), so one connection with ``check_same_thread=False``
plus a lock is simpler and safer on Windows than per-operation connections.
"""
def __init__(self, path: str | Path) -> None:
resolved = Path(path)
resolved.parent.mkdir(parents=True, exist_ok=True)
self._connection = sqlite3.connect(
str(resolved),
check_same_thread=False,
)
self._connection.row_factory = sqlite3.Row
self._lock = threading.Lock()
self._connection.execute("PRAGMA journal_mode=WAL")
self._connection.execute("PRAGMA busy_timeout=5000")
self._connection.execute("PRAGMA synchronous=NORMAL")
self._connection.executescript(SCHEMA)
self._connection.commit()
def execute(
self,
statement: str,
parameters: tuple[Any, ...] = (),
) -> sqlite3.Cursor:
with self._lock:
cursor = self._connection.execute(statement, parameters)
self._connection.commit()
return cursor
def close(self) -> None:
with self._lock:
self._connection.close()
class DeliveryStatsStore:
"""Persistent per-channel delivery counters (read-through / write-through).
Each ``record`` call applies an atomic UPSERT carrying 0/1 increments and
the new ``last_*`` values, so there is no in-memory copy to drift from disk.
"""
def __init__(self, db: Database) -> None:
self._db = db
def get_all(self) -> dict[str, dict[str, Any]]:
rows = self._db.execute(
"SELECT * FROM delivery_stats",
).fetchall()
return {row["channel"]: dict(row) for row in rows}
def record(
self,
channel: str,
*,
success: bool,
error: str | None = None,
test: bool = False,
) -> None:
now = utc_now_iso()
self._db.execute(
"""
INSERT INTO delivery_stats (
channel, delivered, failed, tests, last_status,
last_delivery_at, last_test_at, last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel) DO UPDATE SET
delivered = delivered + excluded.delivered,
failed = failed + excluded.failed,
tests = tests + excluded.tests,
last_status = excluded.last_status,
last_delivery_at = COALESCE(
excluded.last_delivery_at,
delivery_stats.last_delivery_at
),
last_test_at = COALESCE(
excluded.last_test_at,
delivery_stats.last_test_at
),
last_error = excluded.last_error
""",
(
channel,
1 if success else 0,
0 if success else 1,
1 if test else 0,
"success" if success else "failed",
now if not test else None,
now if test else None,
None if success else error,
),
)

View File

@@ -1,571 +0,0 @@
const API_ENDPOINT = "/api/detect";
const VIEW_LABELS = {
overview: "系统总览",
inspection: "视频巡检",
events: "告警中心",
robots: "消息机器人",
risks: "风险台账",
settings: "系统设置",
};
const STATUS_LABELS = {
pending: "待处置",
acknowledged: "已确认",
resolved: "已解决",
};
const elements = Object.fromEntries(
[
"ackEventsCount", "alertStatus", "allEventsCount", "clearButton", "confirmFramesValue", "cooldownValue",
"currentClock", "detectionList", "emptyState", "eventTableBody",
"eventTableMeta", "fireCount", "globalStatus",
"inspectionStatus", "intervalSelect", "lastUpdated", "loadingState",
"maxConfidence", "menuButton", "modelStatusDetail", "modelStatusValue",
"notificationDot", "overlayCanvas", "overviewMessage", "pendingEventCount",
"pendingEventsCount", "pendingNavCount", "readyApi", "readyFeishu",
"readyWechat", "readyWeights", "recentEventList", "refreshEventsButton",
"resolvedEventsCount", "riskChannelValue", "riskConfidenceValue",
"riskPendingValue", "riskStatus", "runDetectionButton", "settingConfidence",
"settingConfirmFrames", "settingCooldown", "settingImageSize", "settingIou",
"settingWeights", "sidebar", "sidebarStatus", "sidebarStatusDot", "smokeCount",
"sourceLabel", "todayEventCount", "toastRegion", "trendChart", "videoClock",
"videoInput", "videoMeta", "videoPreview", "viewBreadcrumb", "viewTitle",
"deviceOnlineRate", "eventClosureRate", "fireRiskRatio",
"notificationCoverage", "onlineRobotCount", "overviewDeliveryCount",
"overviewFeishuState", "overviewPolicyText", "overviewWechatState",
"refreshRobotsButton", "riskDonut", "riskTotal", "robotCardGrid",
"robotConfiguredCount", "robotDeliveredCount", "robotFailedCount",
"robotNavCount", "robotPolicyConfirm", "robotPolicyCooldown",
"robotSummary", "robotTotalCount", "safetyGauge", "safetyScore",
"smokeRiskRatio",
].map((id) => [id, document.querySelector(`#${id}`)])
);
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
let dashboardData = null;
let eventData = [];
let eventFilter = "";
function showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
elements.toastRegion.appendChild(toast);
setTimeout(() => toast.remove(), 3600);
}
function switchView(viewName) {
if (!VIEW_LABELS[viewName]) return;
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName);
});
document.querySelectorAll("[data-view]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.view === viewName);
});
elements.viewTitle.textContent = VIEW_LABELS[viewName];
elements.viewBreadcrumb.textContent = VIEW_LABELS[viewName];
elements.sidebar.classList.remove("is-open");
if (viewName === "events") loadEvents();
if (viewName === "robots") loadRobots();
if (["overview", "risks", "settings"].includes(viewName)) loadDashboard();
}
function setServiceStatus(online) {
elements.globalStatus.textContent = online ? "服务运行正常" : "服务连接异常";
elements.globalStatus.className = `status-chip ${online ? "status-online" : "status-offline"}`;
elements.sidebarStatus.textContent = online ? "服务在线" : "服务离线";
elements.sidebarStatusDot.className = `status-dot ${online ? "is-online" : "is-offline"}`;
elements.readyApi.textContent = online ? "正常" : "异常";
elements.readyApi.className = online ? "ready" : "not-ready";
}
function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}
function updateClock() {
elements.currentClock.textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDate(value) {
if (!value) return "--";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function formatTime(seconds) {
if (!Number.isFinite(seconds)) return "00:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
}
function updateDashboard(data) {
dashboardData = data;
const { summary, system, detection } = data;
const channels = system.alert_channels || {};
const channelNames = enabledChannelNames(channels);
elements.todayEventCount.textContent = String(summary.today);
elements.pendingEventCount.textContent = String(summary.pending);
elements.pendingNavCount.textContent = String(summary.pending);
elements.notificationDot.hidden = summary.pending === 0;
elements.modelStatusValue.textContent = system.weights_available ? "运行正常" : "权重缺失";
elements.modelStatusDetail.textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS";
elements.modelStatusValue.className = "text-value";
const robotPayload = data.robots || { robots: [], summary: {} };
const robotSummary = robotPayload.summary || {};
elements.onlineRobotCount.textContent = String(robotSummary.configured || 0);
elements.robotSummary.textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0}`;
elements.overviewMessage.textContent = summary.pending
? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。`
: "当前无待处置事件,模型与视频巡检服务保持监测状态。";
elements.readyWeights.textContent = system.weights_available ? "已就绪" : "缺失";
elements.readyWeights.className = system.weights_available ? "ready" : "not-ready";
setChannelReady(elements.readyWechat, channels.wechat);
setChannelReady(elements.readyFeishu, channels.feishu);
elements.riskPendingValue.textContent = String(summary.pending);
elements.riskChannelValue.textContent = `${channelNames.length} / 2`;
elements.riskConfidenceValue.textContent = `${Math.round(detection.confidence * 100)}%`;
elements.confirmFramesValue.textContent = `${detection.confirm_frames}`;
elements.cooldownValue.textContent = `${detection.cooldown_seconds}`;
elements.settingWeights.textContent = system.weights;
elements.settingImageSize.textContent = `${detection.image_size} px`;
elements.settingConfidence.textContent = detection.confidence.toFixed(2);
elements.settingIou.textContent = detection.iou.toFixed(2);
elements.settingConfirmFrames.textContent = `${detection.confirm_frames}`;
elements.settingCooldown.textContent = `${detection.cooldown_seconds}`;
renderRecentEvents(data.recent_events || []);
const visualEvents = eventData.length ? eventData : data.recent_events || [];
renderTrend(visualEvents);
updateCommandVisuals(visualEvents, summary, channels, robotSummary);
renderRobots(robotPayload);
}
function updateCommandVisuals(events, summary, channels, robotSummary = {}) {
const totalRobots = robotSummary.total || 2;
const configuredRobots = robotSummary.configured || 0;
const total = summary.total || 0;
const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100;
const notificationRate = Math.round((configuredRobots / totalRobots) * 100);
const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3)));
elements.deviceOnlineRate.textContent = `${notificationRate}%`;
elements.eventClosureRate.textContent = `${closureRate}%`;
elements.notificationCoverage.textContent = `${notificationRate}%`;
elements.safetyScore.textContent = String(safetyScore);
elements.safetyGauge.style.setProperty("--score", safetyScore);
elements.overviewWechatState.textContent = channels.wechat ? "已配置" : "未配置";
elements.overviewFeishuState.textContent = channels.feishu ? "已配置" : "未配置";
elements.overviewPolicyText.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧确认`;
elements.overviewDeliveryCount.textContent = `累计成功 ${robotSummary.delivered || 0}`;
let fireEvents = 0;
let smokeEvents = 0;
events.forEach((event) => {
if ((event.classes || []).includes("fire")) fireEvents += 1;
if ((event.classes || []).includes("smoke")) smokeEvents += 1;
});
const detectedTotal = fireEvents + smokeEvents;
const chartTotal = Math.max(detectedTotal, 1);
const fireArc = Math.round((fireEvents / chartTotal) * 88);
const smokeArc = detectedTotal ? 88 - fireArc : 0;
elements.riskTotal.textContent = String(detectedTotal);
elements.fireRiskRatio.textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`;
elements.smokeRiskRatio.textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`;
elements.riskDonut.style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--orange) ${fireArc + smokeArc}% 100%)`;
}
async function loadRobots() {
try {
const response = await fetch("/api/robots");
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `API ${response.status}`);
renderRobots(result);
setServiceStatus(true);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取机器人状态:${error.message}`, "error");
}
}
function renderRobots(payload = { robots: [], summary: {} }) {
const robots = payload.robots || [];
const summary = payload.summary || {};
elements.robotTotalCount.textContent = String(summary.total || robots.length);
elements.robotConfiguredCount.textContent = String(summary.configured || 0);
elements.robotDeliveredCount.textContent = String(summary.delivered || 0);
elements.robotFailedCount.textContent = String(summary.failed || 0);
elements.robotNavCount.textContent = String(summary.configured || 0);
elements.robotPolicyConfirm.textContent = `${dashboardData?.detection?.confirm_frames || 3} 帧连续命中后发送`;
elements.robotPolicyCooldown.textContent = `${dashboardData?.detection?.cooldown_seconds || 60} 秒内同类不重复发送`;
elements.robotCardGrid.innerHTML = robots.length
? robots.map(robotCardTemplate).join("")
: '<article class="panel robot-empty">暂无机器人通道数据</article>';
}
function robotCardTemplate(robot) {
const platformClass = robot.id === "wechat" ? "wechat" : "feishu";
const platformMark = robot.id === "wechat" ? "微" : "飞";
const statusLabel = robot.configured ? "已配置" : "未配置";
const lastActivity = robot.last_test_at || robot.last_delivery_at;
const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录";
const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送";
const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL";
return `<article class="panel message-robot-card ${robot.configured ? "is-configured" : ""}" data-robot-id="${robot.id}">
<div class="message-robot-head"><div class="platform-logo ${platformClass}">${platformMark}</div><div><span class="robot-id">${robot.id.toUpperCase()} ROBOT</span><h3>${robot.name}群机器人</h3><p>${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}</p></div><span class="robot-status ${robot.configured ? "active" : "offline"}"><i></i>${statusLabel}</span></div>
<div class="capability-list">${robot.capabilities.map((capability) => `<span>${capability}</span>`).join("")}</div>
<div class="delivery-metrics"><div><span>成功投递</span><strong>${robot.delivered}</strong></div><div><span>失败</span><strong class="${robot.failed ? "danger" : ""}">${robot.failed}</strong></div><div><span>连接测试</span><strong>${robot.tests}</strong></div></div>
<div class="robot-activity"><span>${statusText}</span><strong>${activityText}</strong></div>
<div class="credential-key"><span>服务端配置</span><code>${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}</code></div>
${robot.last_error ? `<p class="delivery-error">${escapeHtml(robot.last_error)}</p>` : ""}
<button class="primary-button test-robot-button" type="button" data-test-robot="${robot.id}" ${robot.configured ? "" : "disabled"}>${robot.configured ? "发送测试消息" : "配置后可测试"}</button>
</article>`;
}
async function testRobot(channel, button) {
const originalText = button.textContent;
button.disabled = true;
button.textContent = "正在发送";
try {
const response = await fetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" });
const result = await response.json();
if (!response.ok) throw new Error(result.detail || `API ${response.status}`);
showToast(`${result.robot.name}测试消息发送成功`, "success");
await Promise.all([loadRobots(), loadDashboard()]);
} catch (error) {
showToast(`测试消息发送失败:${error.message}`, "error");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function setChannelReady(element, enabled) {
element.textContent = enabled ? "已启用" : "未配置";
element.className = enabled ? "ready" : "optional";
}
async function loadDashboard() {
try {
const response = await fetch("/api/dashboard");
if (!response.ok) throw new Error(`API ${response.status}`);
const data = await response.json();
setServiceStatus(true);
updateDashboard(data);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取管理数据:${error.message}`, "error");
}
}
async function loadEvents() {
try {
const query = eventFilter ? `?status=${eventFilter}` : "";
const response = await fetch(`/api/events${query}`);
if (!response.ok) throw new Error(`API ${response.status}`);
const result = await response.json();
eventData = result.events || [];
renderEventTable(eventData);
updateEventSummary(result.summary);
renderTrend(eventData);
setServiceStatus(true);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取告警事件:${error.message}`, "error");
}
}
function updateEventSummary(summary) {
elements.allEventsCount.textContent = String(summary.total);
elements.pendingEventsCount.textContent = String(summary.pending);
elements.ackEventsCount.textContent = String(summary.acknowledged);
elements.resolvedEventsCount.textContent = String(summary.resolved);
elements.pendingNavCount.textContent = String(summary.pending);
elements.notificationDot.hidden = summary.pending === 0;
}
function renderRecentEvents(events) {
if (!events.length) {
elements.recentEventList.innerHTML = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
return;
}
elements.recentEventList.innerHTML = events.map((event) => {
const primary = event.classes.includes("fire") ? "fire" : "smoke";
const label = event.classes.map(classLabel).join("、");
return `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${event.id}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></div>`;
}).join("");
}
function renderEventTable(events) {
elements.eventTableMeta.textContent = `${events.length} 条记录`;
if (!events.length) {
elements.eventTableBody.innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
return;
}
elements.eventTableBody.innerHTML = events.map((event) => {
const classes = event.classes.map((name) => `<span class="type-tag type-${name}">${classLabel(name)}</span>`).join(" ");
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
return `<tr><td>${event.id}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${event.status}">${STATUS_LABELS[event.status]}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${event.id}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
}).join("");
}
function classLabel(name) {
return name === "fire" ? "火焰" : "烟雾";
}
async function updateEventStatus(eventId, status) {
try {
const response = await fetch(`/api/events/${eventId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!response.ok) throw new Error(`API ${response.status}`);
showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success");
await Promise.all([loadEvents(), loadDashboard()]);
} catch (error) {
showToast(`事件更新失败:${error.message}`, "error");
}
}
function renderTrend(events) {
const days = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - (6 - index));
return { key: date.toISOString().slice(0, 10), label: `${date.getMonth() + 1}/${date.getDate()}`, fire: 0, smoke: 0 };
});
events.forEach((event) => {
const day = days.find((item) => item.key === String(event.created_at).slice(0, 10));
if (!day) return;
if (event.classes.includes("fire")) day.fire += 1;
if (event.classes.includes("smoke")) day.smoke += 1;
});
const width = 720;
const height = 225;
const padding = { left: 34, right: 12, top: 14, bottom: 28 };
const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke]));
const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6);
const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue);
const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" ");
const gridValues = [0, Math.ceil(maxValue / 2), maxValue];
elements.trendChart.innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
}
function clearDetectionResults() {
elements.smokeCount.textContent = "0";
elements.fireCount.textContent = "0";
elements.maxConfidence.textContent = "--";
elements.riskStatus.textContent = "待机";
elements.detectionList.innerHTML = '<p class="empty-copy">暂无检测结果</p>';
const context = elements.overlayCanvas.getContext("2d");
context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height);
}
function drawDetections(detections = []) {
const video = elements.videoPreview;
if (!video.videoWidth || !video.videoHeight) return;
const canvas = elements.overlayCanvas;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || [];
const color = detection.class === "fire" ? "#ff665e" : "#41d6c3";
context.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
context.fillStyle = color;
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
});
}
function updateDetectionResults(result) {
const detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length;
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
elements.smokeCount.textContent = String(smoke);
elements.fireCount.textContent = String(fire);
elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--";
elements.riskStatus.textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
elements.detectionList.innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
const alert = result.alert || {};
const channelNames = enabledChannelNames(alert.notification_channels);
if (alert.triggered) {
const labels = alert.classes.map(classLabel).join("、");
elements.alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
elements.alertStatus.className = "alert-status alert-triggered";
showToast(`检测到${labels},已生成告警事件`, "error");
loadDashboard();
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
elements.alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
elements.alertStatus.className = "alert-status";
}
drawDetections(detections);
elements.lastUpdated.textContent = `推理 ${result.inference_ms ?? "--"} ms`;
}
function captureFrame() {
const video = elements.videoPreview;
frameCanvas.width = video.videoWidth;
frameCanvas.height = video.videoHeight;
frameCanvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
}
async function detectCurrentFrame(timestamp) {
if (!detectionActive || requestInFlight || !sessionId || elements.videoPreview.paused || elements.videoPreview.ended) return;
const interval = Number(elements.intervalSelect.value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
elements.loadingState.hidden = false;
try {
const frame = await captureFrame();
if (!frame) throw new Error("无法截取视频帧");
const form = new FormData();
form.append("file", frame, "video-frame.jpg");
const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
if (!response.ok) throw new Error(`API ${response.status}`);
updateDetectionResults(await response.json());
elements.inspectionStatus.textContent = "检测运行中";
elements.inspectionStatus.className = "status-chip status-online";
setServiceStatus(true);
} catch (error) {
elements.inspectionStatus.textContent = "检测异常";
elements.inspectionStatus.className = "status-chip status-offline";
showToast(`视频检测失败:${error.message}`, "error");
} finally {
requestInFlight = false;
elements.loadingState.hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
if (!elements.videoPreview.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
elements.runDetectionButton.textContent = "停止检测";
elements.intervalSelect.disabled = true;
try {
await elements.videoPreview.play();
requestAnimationFrame(scheduleDetection);
} catch {
stopDetection();
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
}
}
function stopDetection() {
detectionActive = false;
elements.runDetectionButton.textContent = "开始连续检测";
elements.intervalSelect.disabled = false;
elements.inspectionStatus.textContent = elements.videoPreview.src ? "检测已暂停" : "等待视频";
elements.inspectionStatus.className = "status-chip status-idle";
}
async function resetSession() {
const previous = sessionId;
sessionId = crypto.randomUUID();
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
}
document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => {
control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump));
});
document.querySelectorAll("[data-event-filter]").forEach((button) => {
button.addEventListener("click", () => {
eventFilter = button.dataset.eventFilter;
document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button));
loadEvents();
});
});
elements.refreshRobotsButton.addEventListener("click", loadRobots);
elements.robotCardGrid.addEventListener("click", (event) => {
const button = event.target.closest("[data-test-robot]");
if (button) testRobot(button.dataset.testRobot, button);
});
elements.eventTableBody.addEventListener("click", (event) => {
const button = event.target.closest("[data-event-id]");
if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus);
});
elements.menuButton.addEventListener("click", () => elements.sidebar.classList.toggle("is-open"));
elements.refreshEventsButton.addEventListener("click", loadEvents);
elements.runDetectionButton.addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
elements.videoPreview.addEventListener("ended", stopDetection);
elements.videoPreview.addEventListener("timeupdate", () => {
elements.videoClock.textContent = `${formatTime(elements.videoPreview.currentTime)} / ${formatTime(elements.videoPreview.duration)}`;
});
elements.videoPreview.addEventListener("seeked", async () => { drawDetections([]); await resetSession(); });
elements.videoPreview.addEventListener("resize", () => drawDetections([]));
elements.videoInput.addEventListener("change", async () => {
const [file] = elements.videoInput.files;
if (!file) return;
stopDetection();
elements.videoPreview.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
elements.videoPreview.src = videoUrl;
elements.videoPreview.hidden = false;
elements.emptyState.hidden = true;
elements.sourceLabel.textContent = file.name;
elements.videoMeta.textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
elements.runDetectionButton.disabled = false;
elements.inspectionStatus.textContent = "视频已就绪";
elements.inspectionStatus.className = "status-chip status-online";
await resetSession();
clearDetectionResults();
});
elements.clearButton.addEventListener("click", async () => {
stopDetection();
elements.videoPreview.pause();
elements.videoPreview.removeAttribute("src");
elements.videoPreview.load();
elements.videoPreview.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
elements.videoInput.value = "";
elements.sourceLabel.textContent = "未选择视频";
elements.videoMeta.textContent = "支持 MP4、WebM 等浏览器可播放格式";
elements.emptyState.hidden = false;
elements.runDetectionButton.disabled = true;
await resetSession();
clearDetectionResults();
});
updateClock();
setInterval(updateClock, 1000);
resetSession();
renderRobots();
Promise.all([loadDashboard(), loadEvents()]);

View File

@@ -68,7 +68,7 @@
<div class="dashboard-command-grid"> <div class="dashboard-command-grid">
<article class="panel safety-score-panel"> <article class="panel safety-score-panel">
<div class="panel-header"><div><p class="section-kicker">SAFETY INDEX</p><h3>综合安全指数</h3></div><span class="trend-badge">较昨日 +2.4%</span></div> <div class="panel-header"><div><p class="section-kicker">SAFETY INDEX</p><h3>综合安全指数</h3></div></div>
<div class="safety-score-body"><div id="safetyGauge" class="safety-gauge" style="--score: 86"><div><strong id="safetyScore">86</strong><span>安全</span></div></div><div class="score-breakdown"><div><span>机器人可用率</span><strong id="deviceOnlineRate">--</strong></div><div><span>事件闭环率</span><strong id="eventClosureRate">--</strong></div><div><span>通知覆盖率</span><strong id="notificationCoverage">--</strong></div></div></div> <div class="safety-score-body"><div id="safetyGauge" class="safety-gauge" style="--score: 86"><div><strong id="safetyScore">86</strong><span>安全</span></div></div><div class="score-breakdown"><div><span>机器人可用率</span><strong id="deviceOnlineRate">--</strong></div><div><span>事件闭环率</span><strong id="eventClosureRate">--</strong></div><div><span>通知覆盖率</span><strong id="notificationCoverage">--</strong></div></div></div>
</article> </article>
<article class="panel message-flow-panel"> <article class="panel message-flow-panel">
@@ -84,7 +84,7 @@
</article> </article>
<article class="panel risk-donut-panel"> <article class="panel risk-donut-panel">
<div class="panel-header"><div><p class="section-kicker">RISK MIX</p><h3>风险类型分布</h3></div><span class="micro-copy">近 7 日</span></div> <div class="panel-header"><div><p class="section-kicker">RISK MIX</p><h3>风险类型分布</h3></div><span class="micro-copy">近 7 日</span></div>
<div class="donut-layout"><div id="riskDonut" class="risk-donut"><div><strong id="riskTotal">0</strong><span>事件</span></div></div><div class="donut-legend"><div><span><i class="legend-fire"></i>火焰</span><strong id="fireRiskRatio">0%</strong></div><div><span><i class="legend-smoke"></i>烟雾</span><strong id="smokeRiskRatio">0%</strong></div><div><span><i class="legend-device"></i>设备</span><strong>12%</strong></div></div></div> <div class="donut-layout"><div id="riskDonut" class="risk-donut"><div><strong id="riskTotal">0</strong><span>事件</span></div></div><div class="donut-legend"><div><span><i class="legend-fire"></i>火焰</span><strong id="fireRiskRatio">0%</strong></div><div><span><i class="legend-smoke"></i>烟雾</span><strong id="smokeRiskRatio">0%</strong></div></div></div>
</article> </article>
</div> </div>
@@ -165,7 +165,7 @@
<div class="fleet-stat-grid"> <div class="fleet-stat-grid">
<article class="fleet-stat"><span>支持平台</span><strong id="robotTotalCount">0</strong><small>企业微信与飞书</small></article> <article class="fleet-stat"><span>支持平台</span><strong id="robotTotalCount">0</strong><small>企业微信与飞书</small></article>
<article class="fleet-stat"><span>已配置</span><strong id="robotConfiguredCount">0</strong><small>可接收告警消息</small></article> <article class="fleet-stat"><span>已配置</span><strong id="robotConfiguredCount">0</strong><small>可接收告警消息</small></article>
<article class="fleet-stat"><span>成功投递</span><strong id="robotDeliveredCount">0</strong><small>本次服务运行期间</small></article> <article class="fleet-stat"><span>成功投递</span><strong id="robotDeliveredCount">0</strong><small>累计(已持久化)</small></article>
<article class="fleet-stat"><span>发送失败</span><strong id="robotFailedCount">0</strong><small>需要检查网络或凭据</small></article> <article class="fleet-stat"><span>发送失败</span><strong id="robotFailedCount">0</strong><small>需要检查网络或凭据</small></article>
</div> </div>
<div class="robot-management-grid message-robot-layout"> <div class="robot-management-grid message-robot-layout">
@@ -192,7 +192,7 @@
<div class="page-intro"><div><p class="section-kicker">SYSTEM SETTINGS</p><h2>系统设置</h2><p>查看当前生效的模型和告警参数。配置修改后需重启服务。</p></div></div> <div class="page-intro"><div><p class="section-kicker">SYSTEM SETTINGS</p><h2>系统设置</h2><p>查看当前生效的模型和告警参数。配置修改后需重启服务。</p></div></div>
<div class="settings-grid"> <div class="settings-grid">
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">MODEL</p><h3>模型配置</h3></div></div><dl><div><dt>权重文件</dt><dd id="settingWeights">--</dd></div><div><dt>输入尺寸</dt><dd id="settingImageSize">--</dd></div><div><dt>置信度阈值</dt><dd id="settingConfidence">--</dd></div><div><dt>IOU 阈值</dt><dd id="settingIou">--</dd></div></dl></article> <article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">MODEL</p><h3>模型配置</h3></div></div><dl><div><dt>权重文件</dt><dd id="settingWeights">--</dd></div><div><dt>输入尺寸</dt><dd id="settingImageSize">--</dd></div><div><dt>置信度阈值</dt><dd id="settingConfidence">--</dd></div><div><dt>IOU 阈值</dt><dd id="settingIou">--</dd></div></dl></article>
<article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">ALERT POLICY</p><h3>告警策略</h3></div></div><dl><div><dt>连续确认</dt><dd id="settingConfirmFrames">--</dd></div><div><dt>分类冷却</dt><dd id="settingCooldown">--</dd></div><div><dt>事件存储</dt><dd>进程内最近 500 条</dd></div><div><dt>视频处理</dt><dd>浏览器本地抽帧</dd></div></dl></article> <article class="panel settings-card"><div class="panel-header"><div><p class="section-kicker">ALERT POLICY</p><h3>告警策略</h3></div></div><dl><div><dt>连续确认</dt><dd id="settingConfirmFrames">--</dd></div><div><dt>分类冷却</dt><dd id="settingCooldown">--</dd></div><div><dt>事件存储</dt><dd>SQLite 持久化 · 最近 500 条</dd></div><div><dt>视频处理</dt><dd>浏览器本地抽帧</dd></div></dl></article>
</div> </div>
<article class="panel env-panel"><div class="panel-header"><div><p class="section-kicker">ENVIRONMENT</p><h3>环境变量参考</h3></div></div><pre><code>YOLO_WEIGHTS=... <article class="panel env-panel"><div class="panel-header"><div><p class="section-kicker">ENVIRONMENT</p><h3>环境变量参考</h3></div></div><pre><code>YOLO_WEIGHTS=...
YOLO_DEVICE=0 YOLO_DEVICE=0
@@ -206,6 +206,6 @@ ALERT_COOLDOWN_SECONDS=60</code></pre></article>
</main> </main>
</div> </div>
<div id="toastRegion" class="toast-region" aria-live="polite"></div> <div id="toastRegion" class="toast-region" aria-live="polite"></div>
<script src="./app.js" type="module"></script> <script src="./main.js" type="module"></script>
</body> </body>
</html> </html>

37
frontend/main.js Normal file
View File

@@ -0,0 +1,37 @@
import { qs, VIEW_LABELS } from "./modules/dom.js";
import { updateClock } from "./modules/ui.js";
import { loadDashboard } from "./views/dashboard.js";
import { loadEvents, initEvents } from "./views/events.js";
import { loadRobots, initRobots } from "./views/robots.js";
import { initInspection, resetSession } from "./views/inspection.js";
import { renderRobots } from "./views/robotCards.js";
function switchView(viewName) {
if (!VIEW_LABELS[viewName]) return;
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
panel.classList.toggle("is-active", panel.dataset.viewPanel === viewName);
});
document.querySelectorAll("[data-view]").forEach((button) => {
button.classList.toggle("is-active", button.dataset.view === viewName);
});
qs("#viewTitle").textContent = VIEW_LABELS[viewName];
qs("#viewBreadcrumb").textContent = VIEW_LABELS[viewName];
qs("#sidebar").classList.remove("is-open");
if (viewName === "events") loadEvents();
if (viewName === "robots") loadRobots();
if (["overview", "risks", "settings"].includes(viewName)) loadDashboard();
}
document.querySelectorAll("[data-view], [data-view-jump]").forEach((control) => {
control.addEventListener("click", () => switchView(control.dataset.view || control.dataset.viewJump));
});
qs("#menuButton").addEventListener("click", () => qs("#sidebar").classList.toggle("is-open"));
initInspection();
initEvents();
initRobots();
updateClock();
setInterval(updateClock, 1000);
resetSession();
renderRobots();
Promise.all([loadDashboard(), loadEvents()]);

8
frontend/modules/api.js Normal file
View File

@@ -0,0 +1,8 @@
export async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.detail || `API ${response.status}`);
}
return result;
}

View File

@@ -0,0 +1,30 @@
import { qs } from "./dom.js";
export function renderTrend(events = []) {
const days = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - (6 - index));
return {
key: date.toISOString().slice(0, 10),
label: `${date.getMonth() + 1}/${date.getDate()}`,
fire: 0,
smoke: 0,
};
});
events.forEach((event) => {
const classes = event.classes || [];
const day = days.find((item) => item.key === String(event.created_at).slice(0, 10));
if (!day) return;
if (classes.includes("fire")) day.fire += 1;
if (classes.includes("smoke")) day.smoke += 1;
});
const width = 720;
const height = 225;
const padding = { left: 34, right: 12, top: 14, bottom: 28 };
const maxValue = Math.max(3, ...days.flatMap((day) => [day.fire, day.smoke]));
const x = (index) => padding.left + index * ((width - padding.left - padding.right) / 6);
const y = (value) => height - padding.bottom - value * ((height - padding.top - padding.bottom) / maxValue);
const path = (key) => days.map((day, index) => `${index ? "L" : "M"}${x(index)},${y(day[key])}`).join(" ");
const gridValues = [0, Math.ceil(maxValue / 2), maxValue];
qs("#trendChart").innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><title>最近七天火焰与烟雾告警趋势</title>${gridValues.map((value) => `<line class="chart-grid" x1="${padding.left}" x2="${width - padding.right}" y1="${y(value)}" y2="${y(value)}"></line><text class="chart-axis-label" x="4" y="${y(value) + 3}">${value}</text>`).join("")}<path class="chart-fire" d="${path("fire")}"></path><path class="chart-smoke" d="${path("smoke")}"></path>${days.map((day, index) => `<circle class="chart-point-fire" cx="${x(index)}" cy="${y(day.fire)}" r="3"></circle><circle class="chart-point-smoke" cx="${x(index)}" cy="${y(day.smoke)}" r="3"></circle><text class="chart-axis-label" text-anchor="middle" x="${x(index)}" y="${height - 8}">${day.label}</text>`).join("")}</svg>`;
}

57
frontend/modules/dom.js Normal file
View File

@@ -0,0 +1,57 @@
const elementCache = new Map();
export function qs(selector) {
if (!elementCache.has(selector)) {
elementCache.set(selector, document.querySelector(selector));
}
return elementCache.get(selector);
}
export function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
export const VIEW_LABELS = {
overview: "系统总览",
inspection: "视频巡检",
events: "告警中心",
robots: "消息机器人",
risks: "风险台账",
settings: "系统设置",
};
export const STATUS_LABELS = {
pending: "待处置",
acknowledged: "已确认",
resolved: "已解决",
};
const CLASS_LABELS = { fire: "火焰", smoke: "烟雾" };
export function classLabel(name) {
return CLASS_LABELS[name] || escapeHtml(String(name));
}
export function formatDate(value) {
if (!value) return "--";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
export function formatTime(seconds) {
if (!Number.isFinite(seconds)) return "00:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
}
export function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}

View File

@@ -0,0 +1,6 @@
// Shared view state. Keeping it here (instead of inside one view module)
// lets views depend on each other without import cycles.
export const store = {
dashboardData: null,
eventData: [],
};

36
frontend/modules/ui.js Normal file
View File

@@ -0,0 +1,36 @@
import { qs } from "./dom.js";
export function showToast(message, type = "info") {
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
qs("#toastRegion").appendChild(toast);
setTimeout(() => toast.remove(), 3600);
}
export function setServiceStatus(online) {
const statusChip = qs("#globalStatus");
statusChip.textContent = online ? "服务运行正常" : "服务连接异常";
statusChip.className = `status-chip ${online ? "status-online" : "status-offline"}`;
qs("#sidebarStatus").textContent = online ? "服务在线" : "服务离线";
qs("#sidebarStatusDot").className = `status-dot ${online ? "is-online" : "is-offline"}`;
const readyApi = qs("#readyApi");
readyApi.textContent = online ? "正常" : "异常";
readyApi.className = online ? "ready" : "not-ready";
}
export function setChannelReady(element, enabled) {
element.textContent = enabled ? "已启用" : "未配置";
element.className = enabled ? "ready" : "optional";
}
export function updateClock() {
qs("#currentClock").textContent = new Date().toLocaleString("zh-CN", {
hour12: false,
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}

View File

@@ -207,7 +207,6 @@ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-even
.stat-card:hover { transform: translateY(-2px); border-color: #31545d; } .stat-card:hover { transform: translateY(-2px); border-color: #31545d; }
.dashboard-command-grid { display: grid; grid-template-columns: minmax(280px, .9fr) minmax(390px, 1.35fr) minmax(260px, .78fr); gap: 14px; margin-top: 14px; } .dashboard-command-grid { display: grid; grid-template-columns: minmax(280px, .9fr) minmax(390px, 1.35fr) minmax(260px, .78fr); gap: 14px; margin-top: 14px; }
.dashboard-command-grid .panel { min-height: 290px; } .dashboard-command-grid .panel { min-height: 290px; }
.trend-badge { border: 1px solid #29664b; padding: 5px 8px; background: var(--green-soft); color: var(--green); font-size: 9px; }
.safety-score-body { display: grid; height: calc(100% - 48px); grid-template-columns: 150px 1fr; align-items: center; gap: 20px; } .safety-score-body { display: grid; height: calc(100% - 48px); grid-template-columns: 150px 1fr; align-items: center; gap: 20px; }
.safety-gauge { --score: 86; position: relative; display: grid; width: 144px; height: 144px; place-items: center; border-radius: 50%; background: conic-gradient(var(--cyan) calc(var(--score) * 1%), #1a3039 0); box-shadow: 0 0 36px #41d6c316; } .safety-gauge { --score: 86; position: relative; display: grid; width: 144px; height: 144px; place-items: center; border-radius: 50%; background: conic-gradient(var(--cyan) calc(var(--score) * 1%), #1a3039 0); box-shadow: 0 0 36px #41d6c316; }
.safety-gauge::before { content: ""; position: absolute; inset: 12px; border: 1px solid #2c444d; border-radius: 50%; background: var(--surface); } .safety-gauge::before { content: ""; position: absolute; inset: 12px; border: 1px solid #2c444d; border-radius: 50%; background: var(--surface); }
@@ -222,9 +221,9 @@ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-even
.message-flow { display: grid; min-height: 190px; grid-template-columns: minmax(95px, 1fr) 36px minmax(95px, 1fr) 36px minmax(120px, 1.2fr); align-items: center; gap: 8px; border: 1px solid var(--line); padding: 18px; background: linear-gradient(145deg, #0b1820, #0e222b); } .message-flow { display: grid; min-height: 190px; grid-template-columns: minmax(95px, 1fr) 36px minmax(95px, 1fr) 36px minmax(120px, 1.2fr); align-items: center; gap: 8px; border: 1px solid var(--line); padding: 18px; background: linear-gradient(145deg, #0b1820, #0e222b); }
.flow-node { display: grid; min-height: 100px; align-content: center; justify-items: center; gap: 7px; border: 1px solid #2a4650; padding: 12px; background: #102630c7; text-align: center; }.flow-node > span { color: var(--cyan); font-size: 9px; letter-spacing: .12em; }.flow-node strong { font-size: 11px; }.flow-node small { color: var(--muted); font-size: 8px; line-height: 1.5; }.flow-node.policy { border-color: #37645e; background: #13302f; }.flow-node.channel { min-height: 76px; grid-template-columns: 28px 1fr; justify-items: start; gap: 2px 9px; text-align: left; }.flow-node.channel > span { grid-row: 1 / 3; display: grid; width: 28px; height: 28px; place-items: center; color: white; font-weight: 800; }.flow-node.channel small { grid-column: 2; }.wechat-dot { background: #20b969; }.feishu-dot { background: #3370ff; }.flow-destinations { display: grid; gap: 9px; }.flow-connector { position: relative; height: 1px; background: #31515b; }.flow-connector::after { content: ""; position: absolute; top: -3px; right: -1px; border-width: 4px 0 4px 6px; border-style: solid; border-color: transparent transparent transparent var(--cyan); }.flow-foot { display: flex; justify-content: space-between; gap: 15px; padding-top: 12px; color: var(--muted); font-size: 9px; }.flow-foot strong { color: var(--cyan); font-size: 9px; } .flow-node { display: grid; min-height: 100px; align-content: center; justify-items: center; gap: 7px; border: 1px solid #2a4650; padding: 12px; background: #102630c7; text-align: center; }.flow-node > span { color: var(--cyan); font-size: 9px; letter-spacing: .12em; }.flow-node strong { font-size: 11px; }.flow-node small { color: var(--muted); font-size: 8px; line-height: 1.5; }.flow-node.policy { border-color: #37645e; background: #13302f; }.flow-node.channel { min-height: 76px; grid-template-columns: 28px 1fr; justify-items: start; gap: 2px 9px; text-align: left; }.flow-node.channel > span { grid-row: 1 / 3; display: grid; width: 28px; height: 28px; place-items: center; color: white; font-weight: 800; }.flow-node.channel small { grid-column: 2; }.wechat-dot { background: #20b969; }.feishu-dot { background: #3370ff; }.flow-destinations { display: grid; gap: 9px; }.flow-connector { position: relative; height: 1px; background: #31515b; }.flow-connector::after { content: ""; position: absolute; top: -3px; right: -1px; border-width: 4px 0 4px 6px; border-style: solid; border-color: transparent transparent transparent var(--cyan); }.flow-foot { display: flex; justify-content: space-between; gap: 15px; padding-top: 12px; color: var(--muted); font-size: 9px; }.flow-foot strong { color: var(--cyan); font-size: 9px; }
.donut-layout { display: grid; height: calc(100% - 45px); align-items: center; justify-items: center; gap: 20px; } .donut-layout { display: grid; height: calc(100% - 45px); align-items: center; justify-items: center; gap: 20px; }
.risk-donut { position: relative; display: grid; width: 142px; height: 142px; place-items: center; border-radius: 50%; background: conic-gradient(var(--red) 0 45%, var(--cyan) 45% 78%, var(--orange) 78% 100%); } .risk-donut { position: relative; display: grid; width: 142px; height: 142px; place-items: center; border-radius: 50%; background: conic-gradient(var(--line) 0 100%); }
.risk-donut::before { content: ""; position: absolute; inset: 18px; border-radius: 50%; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }.risk-donut > div { position: relative; display: grid; justify-items: center; }.risk-donut strong { font-size: 30px; }.risk-donut span { color: var(--muted); font-size: 9px; } .risk-donut::before { content: ""; position: absolute; inset: 18px; border-radius: 50%; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); }.risk-donut > div { position: relative; display: grid; justify-items: center; }.risk-donut strong { font-size: 30px; }.risk-donut span { color: var(--muted); font-size: 9px; }
.donut-legend { display: grid; width: 100%; gap: 8px; }.donut-legend div { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); padding: 7px 0; font-size: 10px; }.donut-legend span { display: flex; align-items: center; gap: 7px; color: var(--muted); }.donut-legend i { width: 8px; height: 8px; }.legend-device { display: inline-block; background: var(--orange); } .donut-legend { display: grid; width: 100%; gap: 8px; }.donut-legend div { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line-soft); padding: 7px 0; font-size: 10px; }.donut-legend span { display: flex; align-items: center; gap: 7px; color: var(--muted); }.donut-legend i { width: 8px; height: 8px; }
.overview-grid-lower { margin-top: 14px; } .overview-grid-lower { margin-top: 14px; }
/* Messaging robots */ /* Messaging robots */

104
frontend/views/dashboard.js Normal file
View File

@@ -0,0 +1,104 @@
import { qs, escapeHtml, formatDate, STATUS_LABELS, classLabel, enabledChannelNames } from "../modules/dom.js";
import { apiFetch } from "../modules/api.js";
import { showToast, setServiceStatus, setChannelReady } from "../modules/ui.js";
import { renderTrend } from "../modules/charts.js";
import { renderRobots } from "./robotCards.js";
import { store } from "../modules/store.js";
export async function loadDashboard() {
try {
const data = await apiFetch("/api/dashboard");
setServiceStatus(true);
updateDashboard(data);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取管理数据:${error.message}`, "error");
}
}
function updateDashboard(data) {
store.dashboardData = data;
const { summary, system, detection } = data;
const channels = system.alert_channels || {};
const channelNames = enabledChannelNames(channels);
qs("#todayEventCount").textContent = String(summary.today);
qs("#pendingEventCount").textContent = String(summary.pending);
qs("#pendingNavCount").textContent = String(summary.pending);
qs("#notificationDot").hidden = summary.pending === 0;
qs("#modelStatusValue").textContent = system.weights_available ? "运行正常" : "权重缺失";
qs("#modelStatusDetail").textContent = system.weights_available ? "模型文件已就绪" : "请检查 YOLO_WEIGHTS";
qs("#modelStatusValue").className = "text-value";
const robotPayload = data.robots || { robots: [], summary: {} };
const robotSummary = robotPayload.summary || {};
qs("#onlineRobotCount").textContent = String(robotSummary.configured || 0);
qs("#robotSummary").textContent = `${robotSummary.configured || 0} / ${robotSummary.total || 2} 已配置 · 成功投递 ${robotSummary.delivered || 0}`;
qs("#overviewMessage").textContent = summary.pending
? `当前有 ${summary.pending} 条告警事件等待处置,请尽快进入告警中心确认。`
: "当前无待处置事件,模型与视频巡检服务保持监测状态。";
qs("#readyWeights").textContent = system.weights_available ? "已就绪" : "缺失";
qs("#readyWeights").className = system.weights_available ? "ready" : "not-ready";
setChannelReady(qs("#readyWechat"), channels.wechat);
setChannelReady(qs("#readyFeishu"), channels.feishu);
qs("#riskPendingValue").textContent = String(summary.pending);
qs("#riskChannelValue").textContent = `${channelNames.length} / 2`;
qs("#riskConfidenceValue").textContent = `${Math.round(detection.confidence * 100)}%`;
qs("#confirmFramesValue").textContent = `${detection.confirm_frames}`;
qs("#cooldownValue").textContent = `${detection.cooldown_seconds}`;
qs("#settingWeights").textContent = system.weights;
qs("#settingImageSize").textContent = `${detection.image_size} px`;
qs("#settingConfidence").textContent = detection.confidence.toFixed(2);
qs("#settingIou").textContent = detection.iou.toFixed(2);
qs("#settingConfirmFrames").textContent = `${detection.confirm_frames}`;
qs("#settingCooldown").textContent = `${detection.cooldown_seconds}`;
renderRecentEvents(data.recent_events || []);
const visualEvents = store.eventData.length ? store.eventData : data.recent_events || [];
renderTrend(visualEvents);
updateCommandVisuals(visualEvents, summary, channels, robotSummary);
renderRobots(robotPayload, detection);
}
function updateCommandVisuals(events, summary, channels, robotSummary = {}) {
const totalRobots = robotSummary.total || 2;
const configuredRobots = robotSummary.configured || 0;
const total = summary.total || 0;
const closureRate = total ? Math.round(((summary.resolved || 0) / total) * 100) : 100;
const notificationRate = Math.round((configuredRobots / totalRobots) * 100);
const safetyScore = Math.max(42, Math.min(98, Math.round(notificationRate * 0.35 + closureRate * 0.35 + Math.max(0, 100 - (summary.pending || 0) * 8) * 0.3)));
qs("#deviceOnlineRate").textContent = `${notificationRate}%`;
qs("#eventClosureRate").textContent = `${closureRate}%`;
qs("#notificationCoverage").textContent = `${notificationRate}%`;
qs("#safetyScore").textContent = String(safetyScore);
qs("#safetyGauge").style.setProperty("--score", safetyScore);
qs("#overviewWechatState").textContent = channels.wechat ? "已配置" : "未配置";
qs("#overviewFeishuState").textContent = channels.feishu ? "已配置" : "未配置";
qs("#overviewPolicyText").textContent = `${store.dashboardData?.detection?.confirm_frames || 3} 帧确认`;
qs("#overviewDeliveryCount").textContent = `累计成功 ${robotSummary.delivered || 0}`;
let fireEvents = 0;
let smokeEvents = 0;
events.forEach((event) => {
const classes = event.classes || [];
if (classes.includes("fire")) fireEvents += 1;
if (classes.includes("smoke")) smokeEvents += 1;
});
const detectedTotal = fireEvents + smokeEvents;
const fireArc = detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0;
const smokeArc = detectedTotal ? 100 - fireArc : 0;
qs("#riskTotal").textContent = String(detectedTotal);
qs("#fireRiskRatio").textContent = `${detectedTotal ? Math.round((fireEvents / detectedTotal) * 100) : 0}%`;
qs("#smokeRiskRatio").textContent = `${detectedTotal ? Math.round((smokeEvents / detectedTotal) * 100) : 0}%`;
qs("#riskDonut").style.background = `conic-gradient(var(--red) 0 ${fireArc}%, var(--cyan) ${fireArc}% ${fireArc + smokeArc}%, var(--line) ${fireArc + smokeArc}% 100%)`;
}
function renderRecentEvents(events) {
if (!events.length) {
qs("#recentEventList").innerHTML = "<p>暂无告警事件。开始视频巡检后,满足连续帧条件的告警会显示在这里。</p>";
return;
}
qs("#recentEventList").innerHTML = events.map((event) => {
const classes = event.classes || [];
const primary = classes.includes("fire") ? "fire" : "smoke";
const label = classes.map(classLabel).join("、");
return `<div class="event-list-item"><span class="event-type-icon ${primary}">${primary === "fire" ? "火" : "烟"}</span><div class="event-description"><strong>${label}检测告警</strong><span>${formatDate(event.created_at)} · ${escapeHtml(event.id)}</span></div><strong class="event-confidence">${Math.round(event.max_confidence * 100)}%</strong><span class="event-status status-${escapeHtml(event.status)}">${STATUS_LABELS[event.status] || escapeHtml(event.status)}</span></div>`;
}).join("");
}

77
frontend/views/events.js Normal file
View File

@@ -0,0 +1,77 @@
import { qs, escapeHtml, STATUS_LABELS, classLabel, formatDate, enabledChannelNames } from "../modules/dom.js";
import { apiFetch } from "../modules/api.js";
import { showToast, setServiceStatus } from "../modules/ui.js";
import { renderTrend } from "../modules/charts.js";
import { store } from "../modules/store.js";
import { loadDashboard } from "./dashboard.js";
let eventFilter = "";
export async function loadEvents() {
try {
const query = eventFilter ? `?status=${eventFilter}` : "";
const result = await apiFetch(`/api/events${query}`);
store.eventData = result.events || [];
renderEventTable(store.eventData);
updateEventSummary(result.summary);
renderTrend(store.eventData);
setServiceStatus(true);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取告警事件:${error.message}`, "error");
}
}
function updateEventSummary(summary) {
qs("#allEventsCount").textContent = String(summary.total);
qs("#pendingEventsCount").textContent = String(summary.pending);
qs("#ackEventsCount").textContent = String(summary.acknowledged);
qs("#resolvedEventsCount").textContent = String(summary.resolved);
qs("#pendingNavCount").textContent = String(summary.pending);
qs("#notificationDot").hidden = summary.pending === 0;
}
function renderEventTable(events) {
qs("#eventTableMeta").textContent = `${events.length} 条记录`;
if (!events.length) {
qs("#eventTableBody").innerHTML = '<tr><td colspan="7" class="empty-cell">当前筛选条件下暂无告警事件</td></tr>';
return;
}
qs("#eventTableBody").innerHTML = events.map((event) => {
const classes = (event.classes || [])
.map((name) => `<span class="type-tag type-${escapeHtml(name)}">${classLabel(name)}</span>`)
.join(" ");
const channels = enabledChannelNames(event.notification_channels).join(" + ") || "未发送";
const statusLabel = STATUS_LABELS[event.status] || escapeHtml(event.status);
return `<tr><td>${escapeHtml(event.id)}</td><td>${formatDate(event.created_at)}</td><td>${classes}</td><td>${Math.round(event.max_confidence * 100)}%</td><td>${channels}</td><td><span class="event-status status-${escapeHtml(event.status)}">${statusLabel}</span></td><td><div class="table-actions">${event.status === "pending" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="acknowledged">确认</button>` : ""}${event.status !== "resolved" ? `<button class="table-action" data-event-id="${escapeHtml(event.id)}" data-event-status="resolved">解决</button>` : ""}</div></td></tr>`;
}).join("");
}
export async function updateEventStatus(eventId, status) {
try {
await apiFetch(`/api/events/${encodeURIComponent(eventId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
showToast(`事件 ${eventId} 已更新为${STATUS_LABELS[status]}`, "success");
await Promise.all([loadEvents(), loadDashboard()]);
} catch (error) {
showToast(`事件更新失败:${error.message}`, "error");
}
}
export function initEvents() {
qs("#refreshEventsButton").addEventListener("click", loadEvents);
qs("#eventTableBody").addEventListener("click", (event) => {
const button = event.target.closest("[data-event-id]");
if (button) updateEventStatus(button.dataset.eventId, button.dataset.eventStatus);
});
document.querySelectorAll("[data-event-filter]").forEach((button) => {
button.addEventListener("click", () => {
eventFilter = button.dataset.eventFilter;
document.querySelectorAll("[data-event-filter]").forEach((item) => item.classList.toggle("is-active", item === button));
loadEvents();
});
});
}

View File

@@ -0,0 +1,196 @@
import { qs, classLabel, formatTime, enabledChannelNames } from "../modules/dom.js";
import { apiFetch } from "../modules/api.js";
import { showToast, setServiceStatus } from "../modules/ui.js";
import { loadDashboard } from "./dashboard.js";
const API_ENDPOINT = "/api/detect";
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
function setInspectionStatus(text, className) {
const status = qs("#inspectionStatus");
status.textContent = text;
status.className = className;
}
function clearDetectionResults() {
qs("#smokeCount").textContent = "0";
qs("#fireCount").textContent = "0";
qs("#maxConfidence").textContent = "--";
qs("#riskStatus").textContent = "待机";
qs("#detectionList").innerHTML = '<p class="empty-copy">暂无检测结果</p>';
const context = qs("#overlayCanvas").getContext("2d");
context.clearRect(0, 0, qs("#overlayCanvas").width, qs("#overlayCanvas").height);
}
function drawDetections(detections = []) {
const video = qs("#videoPreview");
if (!video.videoWidth || !video.videoHeight) return;
const canvas = qs("#overlayCanvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || [];
const color = detection.class === "fire" ? "#ff665e" : "#41d6c3";
context.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
context.fillStyle = color;
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
});
}
function updateDetectionResults(result) {
const detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length;
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
qs("#smokeCount").textContent = String(smoke);
qs("#fireCount").textContent = String(fire);
qs("#maxConfidence").textContent = max ? `${Math.round(max * 100)}%` : "--";
qs("#riskStatus").textContent = fire ? "高风险" : smoke ? "需关注" : "正常";
qs("#detectionList").innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${classLabel(item.class)}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="empty-copy">未发现烟雾或火焰目标</p>';
const alert = result.alert || {};
const channelNames = enabledChannelNames(alert.notification_channels);
if (alert.triggered) {
const labels = alert.classes.map(classLabel).join("、");
const alertStatus = qs("#alertStatus");
alertStatus.textContent = channelNames.length ? `已触发 ${channelNames.join(" + ")} 告警:${labels}` : `已生成告警事件:${labels}(机器人未配置)`;
alertStatus.className = "alert-status alert-triggered";
showToast(`检测到${labels},已生成告警事件`, "error");
loadDashboard();
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
const alertStatus = qs("#alertStatus");
alertStatus.textContent = channelNames.length ? `${channelNames.join(" + ")}已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}` : `机器人未配置 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`;
alertStatus.className = "alert-status";
}
drawDetections(detections);
qs("#lastUpdated").textContent = `推理 ${result.inference_ms ?? "--"} ms`;
}
function captureFrame() {
const video = qs("#videoPreview");
frameCanvas.width = video.videoWidth;
frameCanvas.height = video.videoHeight;
frameCanvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
}
async function detectCurrentFrame(timestamp) {
const video = qs("#videoPreview");
if (!detectionActive || requestInFlight || !sessionId || video.paused || video.ended) return;
const interval = Number(qs("#intervalSelect").value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
qs("#loadingState").hidden = false;
try {
const frame = await captureFrame();
if (!frame) throw new Error("无法截取视频帧");
const form = new FormData();
form.append("file", frame, "video-frame.jpg");
const result = await apiFetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
updateDetectionResults(result);
setInspectionStatus("检测运行中", "status-chip status-online");
setServiceStatus(true);
} catch (error) {
setInspectionStatus("检测异常", "status-chip status-offline");
showToast(`视频检测失败:${error.message}`, "error");
} finally {
requestInFlight = false;
qs("#loadingState").hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
const video = qs("#videoPreview");
if (!video.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
qs("#runDetectionButton").textContent = "停止检测";
qs("#intervalSelect").disabled = true;
try {
await video.play();
requestAnimationFrame(scheduleDetection);
} catch {
stopDetection();
showToast("浏览器未允许视频播放,请手动点击播放后重试。", "error");
}
}
function stopDetection() {
detectionActive = false;
qs("#runDetectionButton").textContent = "开始连续检测";
qs("#intervalSelect").disabled = false;
const video = qs("#videoPreview");
setInspectionStatus(video.src ? "检测已暂停" : "等待视频", "status-chip status-idle");
}
export async function resetSession() {
const previous = sessionId;
sessionId = crypto.randomUUID();
if (previous) fetch(`/api/sessions/${encodeURIComponent(previous)}`, { method: "DELETE" }).catch(() => {});
}
export function initInspection() {
const video = qs("#videoPreview");
qs("#runDetectionButton").addEventListener("click", () => detectionActive ? stopDetection() : startDetection());
video.addEventListener("ended", stopDetection);
video.addEventListener("timeupdate", () => {
qs("#videoClock").textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
});
video.addEventListener("seeked", async () => {
drawDetections([]);
await resetSession();
});
video.addEventListener("resize", () => drawDetections([]));
qs("#videoInput").addEventListener("change", async () => {
const [file] = qs("#videoInput").files;
if (!file) return;
stopDetection();
video.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
video.src = videoUrl;
video.hidden = false;
qs("#emptyState").hidden = true;
qs("#sourceLabel").textContent = file.name;
qs("#videoMeta").textContent = `${(file.size / 1024 / 1024).toFixed(1)} MB · 本地视频`;
qs("#runDetectionButton").disabled = false;
setInspectionStatus("视频已就绪", "status-chip status-online");
await resetSession();
clearDetectionResults();
});
qs("#clearButton").addEventListener("click", async () => {
stopDetection();
video.pause();
video.removeAttribute("src");
video.load();
video.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
qs("#videoInput").value = "";
qs("#sourceLabel").textContent = "未选择视频";
qs("#videoMeta").textContent = "支持 MP4、WebM 等浏览器可播放格式";
qs("#emptyState").hidden = false;
qs("#runDetectionButton").disabled = true;
await resetSession();
clearDetectionResults();
});
}

View File

@@ -0,0 +1,39 @@
import { qs, escapeHtml, formatDate } from "../modules/dom.js";
export function robotCardTemplate(robot) {
const id = escapeHtml(robot.id);
const platformClass = robot.id === "wechat" ? "wechat" : "feishu";
const platformMark = robot.id === "wechat" ? "微" : "飞";
const statusLabel = robot.configured ? "已配置" : "未配置";
const lastActivity = robot.last_test_at || robot.last_delivery_at;
const activityText = lastActivity ? formatDate(lastActivity) : "尚无发送记录";
const statusText = robot.last_status === "success" ? "最近发送成功" : robot.last_status === "failed" ? "最近发送失败" : "等待首次发送";
const credential = robot.id === "wechat" ? "WECHAT_WEBHOOK_URL" : "FEISHU_WEBHOOK_URL";
const capabilities = (robot.capabilities || [])
.map((capability) => `<span>${escapeHtml(capability)}</span>`)
.join("");
return `<article class="panel message-robot-card ${robot.configured ? "is-configured" : ""}" data-robot-id="${id}">
<div class="message-robot-head"><div class="platform-logo ${platformClass}">${platformMark}</div><div><span class="robot-id">${escapeHtml(robot.id.toUpperCase())} ROBOT</span><h3>${escapeHtml(robot.name)}群机器人</h3><p>${robot.id === "wechat" ? "群聊 Markdown 与告警截图" : "群聊交互卡片与签名校验"}</p></div><span class="robot-status ${robot.configured ? "active" : "offline"}"><i></i>${statusLabel}</span></div>
<div class="capability-list">${capabilities}</div>
<div class="delivery-metrics"><div><span>成功投递</span><strong>${robot.delivered}</strong></div><div><span>失败</span><strong class="${robot.failed ? "danger" : ""}">${robot.failed}</strong></div><div><span>连接测试</span><strong>${robot.tests}</strong></div></div>
<div class="robot-activity"><span>${statusText}</span><strong>${activityText}</strong></div>
<div class="credential-key"><span>服务端配置</span><code>${credential}${robot.id === "feishu" ? " / FEISHU_SECRET" : ""}</code></div>
${robot.last_error ? `<p class="delivery-error">${escapeHtml(robot.last_error)}</p>` : ""}
<button class="primary-button test-robot-button" type="button" data-test-robot="${id}" ${robot.configured ? "" : "disabled"}>${robot.configured ? "发送测试消息" : "配置后可测试"}</button>
</article>`;
}
export function renderRobots(payload = { robots: [], summary: {} }, policy = {}) {
const robots = payload.robots || [];
const summary = payload.summary || {};
qs("#robotTotalCount").textContent = String(summary.total || robots.length);
qs("#robotConfiguredCount").textContent = String(summary.configured || 0);
qs("#robotDeliveredCount").textContent = String(summary.delivered || 0);
qs("#robotFailedCount").textContent = String(summary.failed || 0);
qs("#robotNavCount").textContent = String(summary.configured || 0);
qs("#robotPolicyConfirm").textContent = `${policy.confirm_frames || 3} 帧连续命中后发送`;
qs("#robotPolicyCooldown").textContent = `${policy.cooldown_seconds || 60} 秒内同类不重复发送`;
qs("#robotCardGrid").innerHTML = robots.length
? robots.map(robotCardTemplate).join("")
: '<article class="panel robot-empty">暂无机器人通道数据</article>';
}

41
frontend/views/robots.js Normal file
View File

@@ -0,0 +1,41 @@
import { qs } from "../modules/dom.js";
import { apiFetch } from "../modules/api.js";
import { showToast, setServiceStatus } from "../modules/ui.js";
import { renderRobots } from "./robotCards.js";
import { store } from "../modules/store.js";
import { loadDashboard } from "./dashboard.js";
export async function loadRobots() {
try {
const result = await apiFetch("/api/robots");
renderRobots(result, store.dashboardData?.detection);
setServiceStatus(true);
} catch (error) {
setServiceStatus(false);
showToast(`无法读取机器人状态:${error.message}`, "error");
}
}
export async function testRobot(channel, button) {
const originalText = button.textContent;
button.disabled = true;
button.textContent = "正在发送";
try {
const result = await apiFetch(`/api/robots/${encodeURIComponent(channel)}/test`, { method: "POST" });
showToast(`${result.robot.name}测试消息发送成功`, "success");
await Promise.all([loadRobots(), loadDashboard()]);
} catch (error) {
showToast(`测试消息发送失败:${error.message}`, "error");
} finally {
button.disabled = false;
button.textContent = originalText;
}
}
export function initRobots() {
qs("#refreshRobotsButton").addEventListener("click", loadRobots);
qs("#robotCardGrid").addEventListener("click", (event) => {
const button = event.target.closest("[data-test-robot]");
if (button) testRobot(button.dataset.testRobot, button);
});
}

View File

@@ -1,11 +1,12 @@
# Pretrained Models # Pretrained Models
本目录保存本地基础模型权重,默认训练权重为 `yolov8n.pt` 本目录保存本地缓存的基础模型权重,供首次训练或实验对照使用。这些文件由 `.gitignore` 排除、不提交到 Git克隆后可从 Ultralytics 官方源重新下载
当前本地权重: 当前本地权重:
- `yolo11s.pt` — 训练默认起点(`DEFAULT_PRETRAINED_MODEL`
- `yolo26s.pt`
- `yolov8n.pt` - `yolov8n.pt`
- `yolov8s.pt` - `yolov8s.pt`
- `yolo26n.pt`
权重文件通过 `.gitignore` 排除,不提交到 Git `TrainConfig.model_weights` 的默认值是已训练好的 `runs/detect/smoke_fire_yolo11s_v1-4/weights/best.pt`,因此 `fire-yolo train` 默认执行微调fine-tune而不是从头训练

View File

@@ -65,5 +65,6 @@ for index, image_path in enumerate(image_paths, start=1):
manifest_path = output_dir / "manifest.json" manifest_path = output_dir / "manifest.json"
import json import json
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"completed={len(manifest)} manifest={manifest_path}") print(f"completed={len(manifest)} manifest={manifest_path}")

View File

@@ -56,8 +56,9 @@ def train(
) )
_configure_ultralytics() _configure_ultralytics()
# Local wall-clock time is the intent for run directory names.
run_name = active_config.name or ( run_name = active_config.name or (
f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}" f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # noqa: DTZ005
) )
model = YOLO(str(weights_path)) model = YOLO(str(weights_path))
results = model.train( results = model.train(

View File

@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from backend.alerting import AlertManager from backend.alerting import AlertManager
from backend.storage import Database, DeliveryStatsStore
class AlertManagerRobotTests(unittest.TestCase): class AlertManagerRobotTests(unittest.TestCase):
@@ -51,6 +54,28 @@ class AlertManagerRobotTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "Unsupported"): with self.assertRaisesRegex(ValueError, "Unsupported"):
manager.test_channel("unknown") manager.test_channel("unknown")
def test_delivery_stats_persist_across_store_instances(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
db_path = Path(tmp_dir) / "app.db"
first_db = Database(db_path)
store = DeliveryStatsStore(first_db)
store.record("wechat", success=True)
store.record("wechat", success=False, error="boom")
store.record("feishu", success=True, test=True)
first_db.close()
second_db = Database(db_path)
reloaded = DeliveryStatsStore(second_db)
stats = reloaded.get_all()
second_db.close()
self.assertEqual(stats["wechat"]["delivered"], 1)
self.assertEqual(stats["wechat"]["failed"], 1)
self.assertEqual(stats["wechat"]["last_status"], "failed")
self.assertEqual(stats["wechat"]["last_error"], "boom")
self.assertEqual(stats["feishu"]["tests"], 1)
self.assertIsNotNone(stats["feishu"]["last_test_at"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import tempfile
import unittest import unittest
from pathlib import Path
from backend.events import EventStore from backend.events import EventStore
from backend.storage import Database
class EventStoreTests(unittest.TestCase): class EventStoreTests(unittest.TestCase):
@@ -53,6 +56,30 @@ class EventStoreTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "Unsupported"): with self.assertRaisesRegex(ValueError, "Unsupported"):
self.store.update(event["id"], "invalid") self.store.update(event["id"], "invalid")
def test_events_persist_across_store_instances(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
db_path = Path(tmp_dir) / "app.db"
first_db = Database(db_path)
first = EventStore(db=first_db)
first.create(
session_id="session",
classes=["smoke"],
detections=[{"class": "smoke", "confidence": 0.55}],
notification_channels={"wechat": False, "feishu": False},
)
first_db.close()
second_db = Database(db_path)
second = EventStore(db=second_db)
events = second.list()
summary_total = second.summary()["total"]
second_db.close()
self.assertEqual(len(events), 1)
self.assertEqual(events[0]["classes"], ["smoke"])
self.assertEqual(events[0]["session_id"], "session")
self.assertEqual(summary_total, 1)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()