CAMWATCH — 局域网摄像头监控系统 Fastapi + html

复制代码
​
​

CAMWATCH --- 局域网摄像头监控系统

基于 FastAPI + 纯 HTML 构建的轻量级局域网摄像头实时监控系统,支持多摄像头同时查看、录像、截图、暂停、配置持久化和历史记录管理。


功能一览

功能 说明
多摄像头管理 同时连接多路摄像头,网格布局实时预览
RTSP / HTTP / USB 支持海康威视、大华、TP-LINK 等主流品牌及 USB 摄像头
实时录像 一键开始/停止录像,输出 MP4 文件
截图 一键截图保存当前画面
暂停 / 恢复 暂停视频流以节省 CPU 和带宽,随时恢复
全屏查看 单路画面全屏放大
配置持久化 摄像头配置自动保存到 config.json,重启服务自动恢复
历史记录 删除的摄像头保留完整配置,支持恢复、编辑后重新添加
在线编辑 直接在页面修改摄像头名称、地址、账号密码
时间戳水印 画面左上角实时叠加日期时间
断线重连 摄像头断线后自动指数退避重连
密码安全 用户名密码单独输入,日志中自动脱敏显示
文件管理 在线下载、删除录像和截图文件

界面预览

┌──────────────────────────────────────────────────────────┐ │ ● CAMWATCH 2 在线 1 录像 0 暂停 3 历史 12MB │ ├──────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ ● 前门摄像头 LIVE │ │ ● 大厅摄像头 LIVE │ │ │ │ ┌─────────────────┐ │ │ ┌─────────────────┐ │ │ │ │ │ 实时视频画面 │ │ │ │ 实时视频画面 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─────────────────┘ │ │ └─────────────────┘ │ │ │ │ 暂停 录像 截图 全屏 │ │ 暂停 录像 截图 全屏 │ │ │ │ rtsp://192.168... │ │ rtsp://192.168... │ │ │ └─────────────────────┘ └─────────────────────┘ │ │ │ │ ┌─────────────────────┐ │ │ │ + 添加摄像头 │ │ │ └─────────────────────┘ │ └──────────────────────────────────────────────────────────┘

复制代码
​
---
​
## 项目结构
​

camwatch/ ├── main.py # FastAPI 后端主程序 ├── requirements.txt # Python 依赖 ├── config.json # 摄像头配置(自动生成) ├── templates/ │ └── index.html # 前端单页应用 ├── recordings/ # 录像文件存储(自动创建) └── snapshots/ # 截图文件存储(自动创建)



快速开始

  1. 安装依赖
    pip install fastapi uvicorn opencv-python-headless numpy python-multipart

或者使用 requirements.txt:

复制代码
pip install -r requirements.txt

requirements.txt:

复制代码
fastapi>=0.104.0
uvicorn>=0.24.0
opencv-python-headless>=4.8.0
numpy>=1.24.0
python-multipart>=0.0.6

macOS / Linux 用户 :如果 opencv-python-headless 安装失败,改用 opencv-python

2. 启动服务

复制代码
python main.py

输出:

复制代码
[config] loaded 0 cameras
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

3. 打开浏览器

复制代码
http://localhost:8000

局域网内其他设备访问:

复制代码
http://<你的电脑IP>:8000

查看本机 IP:

复制代码
# Windows
ipconfig
​
# macOS / Linux
ifconfig
# 或
ip addr

摄像头连接指南

海康威视 (Hikvision)

RTSP 地址格式:

复制代码
地址:   rtsp://<IP>:554/Streaming/Channels/101
用户名: admin
密码:   设备验证码(机身标签上)
通道 地径 说明
通道1 主码流 /Streaming/Channels/101 高清
通道1 子码流 /Streaming/Channels/102 标清,更流畅
通道2 主码流 /Streaming/Channels/201 NVR 第二路

重要:海康摄像头需关闭"安全模式"

  1. 浏览器打开 http://<摄像头IP>

  2. 登录(用户名 admin,密码为机身验证码)

  3. 进入 配置 → 系统 → 安全管理 → 安全模式

  4. 改为 开放模式,保存

部分固件路径为:配置 → 网络 → 高级配置 → 集成协议

RTSP 地址格式:

复制代码
地址:   rtsp://<IP>:554/stream1
用户名: admin
密码:   在 APP 或网页端设置的密码

重要:TP-LINK 需手动开启 RTSP

  1. 浏览器打开 http://<摄像头IP>

  2. 登录

  3. 进入 设置 → 网络 → 高级设置 → 服务

  4. 勾选 RTSP 服务,保存

大华 (Dahua)

RTSP 地址格式:

复制代码
地址:   rtsp://<IP>:554/cam/realmonitor?channel=1&subtype=0
用户名: admin
密码:   设备密码
参数 说明
channel=1 通道1
subtype=0 主码流(高清)
subtype=1 子码流(标清)

USB 摄像头

复制代码
地址:     0
用户名:   (留空)
密码:     (留空)

多个 USB 摄像头依次使用 012 作为地址。

其他品牌 / 通用格式

品牌 RTSP 地址示例
宇视 (Uniview) rtsp://<IP>:554/media/video1
天地伟业 rtsp://<IP>:554/stream1
通用 IP 摄像头 rtsp://<IP>:554/live
手机 IP 摄像头 App http://<IP>:8080/video
RTSP 通用备选 rtsp://<IP>:554/1

测试连接

连接摄像头前,建议先用 VLC 播放器 测试:

  1. 下载安装 VLC

  2. 打开 VLC → 媒体打开网络串流

  3. 输入完整 RTSP 地址(含用户名密码):

复制代码
rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101
  1. 能看到画面 → 在 CAMWATCH 中分别填写:
复制代码
名称:  海康前门
地址:  rtsp://192.168.1.64:554/Streaming/Channels/101
用户:  admin
密码:  password

CAMWATCH 会自动将用户名密码拼入 RTSP 地址,无需手动拼接。


侧栏功能

点击右上角 打开控制面板:

配置状态

显示当前配置文件的保存时间和状态。

  • 摄像头添加/移除时 自动保存

  • 点击顶栏 💾 按钮 手动保存

  • 重启服务后自动恢复所有摄像头

添加 / 编辑摄像头

模式 触发 说明
添加 点击卡片的 "+" 或侧栏表单 新增摄像头
编辑 点击在线摄像头卡片的 "编辑" 按钮 预填表单,修改后更新
历史编辑 点击历史记录的 "编辑" 按钮 用历史配置预填,可修改后重新连接

历史记录

删除的摄像头不会丢失,自动移入历史记录:

操作 说明
恢复 直接恢复摄像头到在线列表
编辑 用历史配置预填表单,可修改后重新连接
永久删除 从历史记录中彻底移除

配置文件

配置自动保存在项目根目录的 config.json

复制代码
{
  "version": 3,
  "saved_at": "2025-01-15 14:30:22",
  "cameras": [
    {
      "id": "a1b2c3d4",
      "name": "前门摄像头",
      "url": "rtsp://192.168.1.64:554/Streaming/Channels/101",
      "user": "admin",
      "pwd": "hik12345"
    }
  ],
  "history": [
    {
      "id": "e5f6g7h8",
      "name": "仓库摄像头",
      "url": "rtsp://192.168.1.108:554/stream1",
      "user": "admin",
      "pwd": "dahua888",
      "deleted_at": "2025-01-15 12:00:00"
    }
  ]
}
字段 说明
cameras 当前在线的摄像头列表
history 已删除的摄像头(保留完整配置)
saved_at 最后保存时间

API 接口

摄像头管理

方法 路径 说明
GET /api/cameras 获取所有摄像头列表
POST /api/cameras 添加摄像头
PUT /api/cameras/{id} 更新摄像头配置
DELETE /api/cameras/{id} 删除摄像头(移入历史)
POST /api/cameras/{id}/pause 切换暂停/恢复
GET /api/stream/{id} MJPEG 视频流

录像与截图

方法 路径 说明
POST /api/cameras/{id}/record/start 开始录像
POST /api/cameras/{id}/record/stop 停止录像
POST /api/cameras/{id}/snapshot 截图

文件管理

方法 路径 说明
GET /api/recordings 录像文件列表
GET /api/recordings/{name}/download 下载录像
DELETE /api/recordings/{name} 删除录像
GET /api/snapshots 截图文件列表
GET /api/snapshots/{name} 查看截图
DELETE /api/snapshots/{name} 删除截图

历史记录

方法 路径 说明
GET /api/history 获取历史记录列表
POST /api/history/{id}/restore 恢复摄像头
DELETE /api/history/{id} 永久删除历史记录

系统

方法 路径 说明
GET /api/system 系统状态信息
POST /api/config/save 手动保存配置

API 使用示例

复制代码
# 添加摄像头
curl -X POST http://localhost:8000/api/cameras \
  -H "Content-Type: application/json" \
  -d '{"name":"前门","url":"rtsp://192.168.1.64:554/Streaming/Channels/101","user":"admin","pwd":"12345"}'
​
# 查看摄像头列表
curl http://localhost:8000/api/cameras
​
# 暂停/恢复摄像头
curl -X POST http://localhost:8000/api/cameras/a1b2c3d4/pause
​
# 开始录像
curl -X POST http://localhost:8000/api/cameras/a1b2c3d4/record/start
​
# 截图
curl -X POST http://localhost:8000/api/cameras/a1b2c3d4/snapshot
​
# 删除摄像头(移入历史)
curl -X DELETE http://localhost:8000/api/cameras/a1b2c3d4
​
# 恢复历史摄像头
curl -X POST http://localhost:8000/api/history/a1b2c3d4/restore
​
# 更新摄像头配置
curl -X PUT http://localhost:8000/api/cameras/a1b2c3d4 \
  -H "Content-Type: application/json" \
  -d '{"name":"前门(已更新)","url":"rtsp://192.168.1.64:554/Streaming/Channels/101","user":"admin","pwd":"newpwd"}'
​
# 系统状态
curl http://localhost:8000/api/system

常见问题

连接摄像头 401 Unauthorized

海康威视: 需关闭安全模式(见上方"海康连接指南"),确保 VLC 能连通。

通用排查:

  • 确认用户名密码正确(非 WiFi 密码)

  • 密码中不要包含 @ # : 等特殊字符

  • 确认摄像头和电脑在同一局域网

连接超时

  • 检查摄像头 IP 是否正确:ping <摄像头IP>

  • 检查 RTSP 端口是否为 554(部分设备为 1554)

  • TP-LINK 需确认已开启 RTSP 服务

连接成功但黑屏

  • 尝试切换码流:/stream1/stream2

  • 海康摄像头尝试子码流:/Streaming/Channels/102

  • 确认 VLC 能正常播放同一地址

暂停后恢复不了

  • 点击画面中央的 播放按钮 或卡片上的 "恢复" 按钮

  • 恢复后需 2-3 秒重新建立连接

如何让其他设备访问

  • 服务默认监听 0.0.0.0:8000

  • 确保防火墙允许 8000 端口

  • 其他设备浏览器打开 http://<服务器IP>:8000

Windows 上 opencv 安装失败

复制代码
pip install opencv-python-headless
# 如果仍失败
pip install opencv-python

技术架构

复制代码
┌──────────────────────────────────────────────────┐
│                    浏览器前端                       │
│           HTML + CSS + JavaScript                  │
│       MJPEG Stream ──► <img> 实时渲染              │
└────────────────────┬─────────────────────────────┘
                     │ HTTP / SSE
┌────────────────────┴─────────────────────────────┐
│                 FastAPI 后端                        │
│  ┌──────────────┐  ┌───────────┐  ┌────────────┐ │
│  │ Camera       │  │ Config    │  │ File       │ │
│  │ Manager      │  │ Manager   │  │ Manager    │ │
│  │ (多线程)      │  │ (JSON)    │  │ (录/截)     │ │
│  └──────┬───────┘  └───────────┘  └────────────┘ │
│         │                                          │
│  ┌──────┴───────┐                                  │
│  │ OpenCV       │                                  │
│  │ VideoCapture │                                  │
│  └──────┬───────┘                                  │
└─────────┼──────────────────────────────────────────┘
          │ RTSP / HTTP / USB
    ┌─────┴─────┐
    │ 摄像头设备  │
    └───────────┘

视频流传输方式: 服务端通过 OpenCV 读取视频帧,编码为 JPEG,通过 HTTP multipart 流式推送到浏览器 <img> 标签,实现接近实时的预览效果。


系统要求

项目 最低要求 推荐
Python 3.9+ 3.11+
CPU 双核 四核(多路摄像头)
内存 2 GB 4 GB+
网络 百兆局域网 千兆局域网
磁盘 1 GB(录像文件) 根据录像需求

每路摄像头约占用:

  • CPU:5-15%(取决于分辨率)

  • 带宽:2-8 Mbps(取决于码流设置)

  • 内存:50-100 MB

代码:

main.py

python 复制代码
import json
import cv2
import os
import time
import uuid
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.parse import quote

import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse, HTMLResponse, FileResponse
from pydantic import BaseModel

app = FastAPI(title="CAMWATCH")

RECORDINGS_DIR = Path("recordings")
SNAPSHOTS_DIR = Path("snapshots")
CONFIG_FILE = Path("config.json")
RECORDINGS_DIR.mkdir(exist_ok=True)
SNAPSHOTS_DIR.mkdir(exist_ok=True)


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Config (cameras + history)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

cameras: dict[str, "Camera"] = {}
history: list[dict] = []
lock = threading.Lock()


def save_config():
    with lock:
        cams = []
        for c in cameras.values():
            cams.append({
                "id": c.id, "name": c.name, "url": c.url,
                "user": c.user, "pwd": c.pwd,
            })
        hist = list(history)
    data = {
        "version": 3,
        "saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "cameras": cams,
        "history": hist,
    }
    CONFIG_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), "utf-8")
    print(f"[config] saved {len(cams)} active, {len(hist)} history")


def load_config():
    global history
    if not CONFIG_FILE.exists():
        return
    try:
        data = json.loads(CONFIG_FILE.read_text("utf-8"))
        cams = data.get("cameras", [])
        history.clear()
        history.extend(data.get("history", []))
        print(f"[config] loaded {len(cams)} cameras, {len(history)} history")
        for cfg in cams:
            cam = Camera(cfg["id"], cfg["name"], cfg["url"],
                         cfg.get("user", ""), cfg.get("pwd", ""))
            cameras[cfg["id"]] = cam
            cam.start()
    except Exception as e:
        print(f"[config] load error: {e}")


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Camera
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

class Camera:
    def __init__(self, cam_id, name, url, user="", pwd=""):
        self.id = cam_id
        self.name = name
        self.url = url
        self.user = user
        self.pwd = pwd
        self.cap: Optional[cv2.VideoCapture] = None
        self.frame: Optional[np.ndarray] = None
        self.frame_lock = threading.Lock()
        self.recording = False
        self.writer: Optional[cv2.VideoWriter] = None
        self.connected = False
        self.paused = False
        self.last_error = ""
        self._stop = threading.Event()
        self._thread: Optional[threading.Thread] = None
        self.started_at: Optional[datetime] = None

    def _build_url(self) -> str:
        url = self.url.strip()
        if not url.lower().startswith("rtsp"):
            return url
        if "@" in url.split("//", 1)[-1]:
            return url
        if not self.user:
            return url
        cred = quote(self.user, safe="")
        if self.pwd:
            cred += ":" + quote(self.pwd, safe="")
        return url.replace("://", f"://{cred}@", 1)

    def start(self):
        self._stop.clear()
        self.paused = False
        self.connected = False
        self.last_error = ""
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()
        self.stop_recording()
        self._release_cap()
        self.connected = False

    def _release_cap(self):
        if self.cap:
            try:
                self.cap.release()
            except Exception:
                pass
            self.cap = None

    def _loop(self):
        delay = 1
        while not self._stop.is_set():
            try:
                # ━━━ PAUSE: release connection, sleep until resumed ━━━
                if self.paused:
                    if self.cap is not None:
                        self._release_cap()
                        self.connected = False
                        self.last_error = ""
                        print(f"[cam:{self.id}] ⏸ paused, connection released")
                    time.sleep(0.5)
                    continue

                # ━━━ CONNECT ━━━
                if self.cap is None or not self.cap.isOpened():
                    self._release_cap()
                    src = self._build_url()
                    print(f"[cam:{self.id}] connecting → {self._mask(src)}")

                    try:
                        self.cap = cv2.VideoCapture(int(src))
                    except (ValueError, TypeError):
                        os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp"
                        self.cap = cv2.VideoCapture(src, cv2.CAP_FFMPEG)

                    if not self.cap or not self.cap.isOpened():
                        self.connected = False
                        self.last_error = "无法打开视频流"
                        print(f"[cam:{self.id}] ✗ cannot open")
                        time.sleep(delay)
                        delay = min(delay * 1.5, 10)
                        continue

                    self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
                    self.connected = True
                    self.started_at = datetime.now()
                    self.last_error = ""
                    delay = 1
                    print(f"[cam:{self.id}] ✓ connected")

                # ━━━ READ FRAME ━━━
                ret, frame = self.cap.read()
                if not ret or frame is None:
                    self.connected = False
                    self.last_error = "读取失败,正在重连..."
                    self._release_cap()
                    time.sleep(0.5)
                    continue

                self.connected = True
                self.last_error = ""

                # Timestamp overlay
                ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                ov = frame.copy()
                cv2.rectangle(ov, (10, 10), (310, 48), (0, 0, 0), -1)
                cv2.addWeighted(ov, 0.6, frame, 0.4, 0, frame)
                cv2.putText(frame, ts, (18, 38),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 136), 2, cv2.LINE_AA)

                with self.frame_lock:
                    self.frame = frame.copy()

                if self.recording and self.writer:
                    try:
                        self.writer.write(frame)
                    except Exception:
                        self.stop_recording()

            except Exception as e:
                if self._stop.is_set():
                    break
                print(f"[cam:{self.id}] error: {e}")
                self.last_error = str(e)
                self.connected = False
                time.sleep(1)

        self._release_cap()

    @staticmethod
    def _mask(url):
        if "@" not in url.split("//", 1)[-1]:
            return url
        try:
            s = url.split("//", 1)
            creds, host = s[1].split("@", 1)
            if ":" in creds:
                creds = creds.split(":", 1)[0] + ":***"
            return f"{s[0]}//{creds}@{host}"
        except Exception:
            return url

    def jpeg(self):
        with self.frame_lock:
            if self.frame is not None:
                _, buf = cv2.imencode(".jpg", self.frame, [cv2.IMWRITE_JPEG_QUALITY, 75])
                return buf.tobytes()
        return None

    def start_recording(self):
        if self.recording:
            return None
        with self.frame_lock:
            if self.frame is None:
                return None
            h, w = self.frame.shape[:2]
        fname = f"{self.name}_{datetime.now():%Y%m%d_%H%M%S}.mp4"
        fourcc = cv2.VideoWriter_fourcc(*"mp4v")
        self.writer = cv2.VideoWriter(str(RECORDINGS_DIR / fname), fourcc, 25.0, (w, h))
        if not self.writer.isOpened():
            self.writer = None
            return None
        self.recording = True
        return fname

    def stop_recording(self):
        if self.writer:
            try:
                self.writer.release()
            except Exception:
                pass
            self.writer = None
        self.recording = False

    def snapshot(self):
        with self.frame_lock:
            if self.frame is None:
                return None
            img = self.frame.copy()
        fname = f"{self.name}_{datetime.now():%Y%m%d_%H%M%S}.jpg"
        cv2.imwrite(str(SNAPSHOTS_DIR / fname), img, [cv2.IMWRITE_JPEG_QUALITY, 95])
        return fname


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Request models
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

class AddReq(BaseModel):
    name: str
    url: str
    user: str = ""
    pwd: str = ""


class UpdateReq(BaseModel):
    name: str
    url: str
    user: str = ""
    pwd: str = ""


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Startup
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.on_event("startup")
async def startup():
    load_config()


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Routes --- page
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.get("/", response_class=HTMLResponse)
async def index():
    p = Path(__file__).parent / "templates" / "index.html"
    return HTMLResponse(p.read_text("utf-8"))


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Routes --- cameras
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.get("/api/cameras")
async def list_cameras():
    with lock:
        return [{
            "id": c.id, "name": c.name, "url": c.url,
            "user": c.user, "pwd": c.pwd,
            "connected": c.connected, "recording": c.recording,
            "paused": c.paused, "error": c.last_error,
            "uptime": (
                str(datetime.now() - c.started_at).split(".")[0]
                if c.started_at and c.connected else None
            ),
        } for c in cameras.values()]


@app.post("/api/cameras")
async def add_camera(req: AddReq):
    cid = uuid.uuid4().hex[:8]
    cam = Camera(cid, req.name, req.url, req.user, req.pwd)
    with lock:
        cameras[cid] = cam
    cam.start()
    save_config()
    return {"id": cid, "name": req.name}


@app.put("/api/cameras/{cid}")
async def update_camera(cid: str, req: UpdateReq):
    with lock:
        old = cameras.get(cid)
    if not old:
        raise HTTPException(404)
    old.stop()
    cam = Camera(cid, req.name, req.url, req.user, req.pwd)
    with lock:
        cameras[cid] = cam
    cam.start()
    save_config()
    return {"ok": True}


@app.delete("/api/cameras/{cid}")
async def remove_camera(cid: str):
    with lock:
        cam = cameras.pop(cid, None)
    if not cam:
        raise HTTPException(404)
    cam.stop()
    entry = {
        "id": cam.id, "name": cam.name, "url": cam.url,
        "user": cam.user, "pwd": cam.pwd,
        "deleted_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    }
    with lock:
        history.append(entry)
    save_config()
    return {"ok": True}


@app.post("/api/cameras/{cid}/pause")
async def pause_cam(cid: str):
    with lock:
        cam = cameras.get(cid)
    if not cam:
        raise HTTPException(404)
    cam.paused = not cam.paused
    if cam.paused:
        if cam.recording:
            cam.stop_recording()
        print(f"[cam:{cid}] ⏸ paused")
    else:
        # Force reconnect
        cam._release_cap()
        cam.connected = False
        cam.last_error = ""
        print(f"[cam:{cid}] ▶ resuming, will reconnect")
    return {"ok": True, "paused": cam.paused}


@app.get("/api/stream/{cid}")
async def stream(cid: str):
    with lock:
        cam = cameras.get(cid)
    if not cam:
        raise HTTPException(404)

    def gen():
        try:
            while True:
                data = cam.jpeg()
                if data:
                    yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + data + b"\r\n")
                else:
                    ph = np.full((480, 640, 3), 12, dtype=np.uint8)
                    cv2.putText(ph, "NO SIGNAL", (180, 250),
                                cv2.FONT_HERSHEY_SIMPLEX, 1.6, (0, 255, 136), 2, cv2.LINE_AA)
                    _, buf = cv2.imencode(".jpg", ph)
                    yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + buf.tobytes() + b"\r\n")
                    time.sleep(0.5)
                    continue
                time.sleep(0.033)
        except GeneratorExit:
            pass

    return StreamingResponse(gen(), media_type="multipart/x-mixed-replace; boundary=frame")


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Routes --- recording / snapshot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.post("/api/cameras/{cid}/record/start")
async def start_rec(cid: str):
    with lock:
        cam = cameras.get(cid)
    if not cam:
        raise HTTPException(404)
    if cam.paused:
        raise HTTPException(400, "摄像头已暂停,请先恢复")
    fn = cam.start_recording()
    if fn:
        return {"ok": True, "filename": fn}
    raise HTTPException(500, "No video signal")


@app.post("/api/cameras/{cid}/record/stop")
async def stop_rec(cid: str):
    with lock:
        cam = cameras.get(cid)
    if not cam:
        raise HTTPException(404)
    cam.stop_recording()
    return {"ok": True}


@app.post("/api/cameras/{cid}/snapshot")
async def snap(cid: str):
    with lock:
        cam = cameras.get(cid)
    if not cam:
        raise HTTPException(404)
    fn = cam.snapshot()
    if fn:
        return {"ok": True, "filename": fn}
    raise HTTPException(500, "No video signal")


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Routes --- history (deleted cameras)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.get("/api/history")
async def list_history():
    with lock:
        return list(history)


@app.post("/api/history/{cid}/restore")
async def restore_camera(cid: str):
    with lock:
        entry = None
        for i, h in enumerate(history):
            if h["id"] == cid:
                entry = history.pop(i)
                break
    if not entry:
        raise HTTPException(404, "历史记录中未找到")
    cam = Camera(entry["id"], entry["name"], entry["url"],
                 entry.get("user", ""), entry.get("pwd", ""))
    with lock:
        cameras[entry["id"]] = cam
    cam.start()
    save_config()
    return {"ok": True, "id": entry["id"], "name": entry["name"]}


@app.delete("/api/history/{cid}")
async def delete_history(cid: str):
    with lock:
        found = False
        for i, h in enumerate(history):
            if h["id"] == cid:
                history.pop(i)
                found = True
                break
    if not found:
        raise HTTPException(404)
    save_config()
    return {"ok": True}


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#  Routes --- config / files / system
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@app.post("/api/config/save")
async def manual_save():
    save_config()
    with lock:
        n = len(cameras)
        h = len(history)
    return {"ok": True, "cameras": n, "history": h}


@app.get("/api/recordings")
async def list_recordings():
    out = []
    for f in sorted(RECORDINGS_DIR.glob("*.mp4"), key=lambda p: p.stat().st_mtime, reverse=True):
        s = f.stat()
        out.append({
            "name": f.name,
            "size": f"{s.st_size / 1048576:.1f} MB",
            "date": datetime.fromtimestamp(s.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
        })
    return out


@app.get("/api/recordings/{name}/download")
async def dl_rec(name: str):
    p = RECORDINGS_DIR / name
    if not p.exists():
        raise HTTPException(404)
    return FileResponse(p, media_type="video/mp4", filename=name)


@app.delete("/api/recordings/{name}")
async def del_rec(name: str):
    p = RECORDINGS_DIR / name
    if not p.exists():
        raise HTTPException(404)
    p.unlink()
    return {"ok": True}


@app.get("/api/snapshots")
async def list_snapshots():
    out = []
    for f in sorted(SNAPSHOTS_DIR.glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True):
        s = f.stat()
        out.append({
            "name": f.name,
            "size": f"{s.st_size / 1024:.1f} KB",
            "date": datetime.fromtimestamp(s.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
        })
    return out


@app.get("/api/snapshots/{name}")
async def get_snap(name: str):
    p = SNAPSHOTS_DIR / name
    if not p.exists():
        raise HTTPException(404)
    return FileResponse(p, media_type="image/jpeg")


@app.delete("/api/snapshots/{name}")
async def del_snap(name: str):
    p = SNAPSHOTS_DIR / name
    if not p.exists():
        raise HTTPException(404)
    p.unlink()
    return {"ok": True}


@app.get("/api/system")
async def sys_info():
    rec_mb = sum(f.stat().st_size for f in RECORDINGS_DIR.glob("*")) / 1048576
    snap_mb = sum(f.stat().st_size for f in SNAPSHOTS_DIR.glob("*")) / 1048576
    with lock:
        hc = len(history)
    return {
        "cameras": len(cameras),
        "connected": sum(1 for c in cameras.values() if c.connected),
        "recording": sum(1 for c in cameras.values() if c.recording),
        "paused": sum(1 for c in cameras.values() if c.paused),
        "history": hc,
        "storage_mb": round(rec_mb + snap_mb, 1),
        "config_saved": CONFIG_FILE.exists(),
        "config_time": (
            json.loads(CONFIG_FILE.read_text("utf-8")).get("saved_at")
            if CONFIG_FILE.exists() else None
        ),
    }


# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

templates/index.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CAMWATCH</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg-deep:#050510;--bg:#0a0a18;--surf:#10101f;--surf-l:#181830;--surf-h:#1e1e3a;
  --accent:#00ff88;--accent-d:#00aa5c;
  --danger:#ff2255;--danger-d:#aa1133;
  --warn:#ffaa00;--warn-d:#cc8800;
  --tx:#e8e8f4;--tx2:#9999b5;--tx3:#666680;
  --bdr:#1a1a35;--bdr2:#252545;
  --r:10px;--rl:14px;
}
html{scrollbar-width:thin;scrollbar-color:var(--bdr2) transparent}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--bdr2);border-radius:3px}
body{
  font-family:'DM Mono',monospace;background:var(--bg-deep);color:var(--tx);
  min-height:100vh;overflow-x:hidden;font-size:15px;line-height:1.6;
}
body::before{
  content:'';position:fixed;inset:0;
  background-image:
    linear-gradient(rgba(0,255,136,.015) 1px,transparent 1px),
    linear-gradient(90deg,rgba(0,255,136,.015) 1px,transparent 1px);
  background-size:48px 48px;pointer-events:none;z-index:0;
}

/* ─── HEADER ─── */
header{
  position:fixed;top:0;left:0;right:0;height:64px;
  background:rgba(10,10,24,.93);backdrop-filter:blur(24px);
  border-bottom:1px solid var(--bdr);
  display:flex;align-items:center;justify-content:space-between;
  padding:0 28px;z-index:100;
}
.logo{
  display:flex;align-items:center;gap:12px;
  font-family:'Syne',sans-serif;font-weight:800;font-size:22px;
  letter-spacing:.12em;color:var(--accent);
}
.logo-dot{
  width:10px;height:10px;background:var(--accent);border-radius:50%;
  box-shadow:0 0 14px var(--accent);animation:pls-g 2s ease-in-out infinite;
}
.hdr-stats{display:flex;gap:26px;font-size:14px;color:var(--tx2)}
.stat{display:flex;align-items:center;gap:9px}
.stat-v{color:var(--tx);font-weight:500;font-size:15px}
.stat-d{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent)}
.stat-d.rec{background:var(--danger);box-shadow:0 0 6px var(--danger);animation:pls-r 1s ease-in-out infinite}
.stat-d.pau{background:var(--warn);box-shadow:0 0 6px var(--warn)}
.stat-d.hist{background:var(--tx3)}
.hdr-acts{display:flex;gap:10px}
.ibtn{
  width:44px;height:44px;display:flex;align-items:center;justify-content:center;
  background:var(--surf);border:1px solid var(--bdr);border-radius:var(--r);
  color:var(--tx2);cursor:pointer;transition:all .2s;font-size:19px;
}
.ibtn:hover{background:var(--surf-l);color:var(--accent);border-color:var(--accent-d)}

/* ─── MAIN ─── */
main{
  padding:82px 28px 36px;position:relative;z-index:1;
  min-height:100vh;transition:margin-right .3s ease;
}
main.sb-open{margin-right:440px}

/* ─── GRID ─── */
.grid{
  display:grid;grid-template-columns:repeat(auto-fill,minmax(480px,1fr));
  gap:22px;max-width:1800px;margin:0 auto;
}
.cam-card{
  background:var(--surf);border:1px solid var(--bdr);border-radius:var(--rl);
  overflow:hidden;opacity:0;animation:fadeUp .5s ease forwards;
  transition:border-color .3s,box-shadow .3s;
}
.cam-card:nth-child(n+2){animation-delay:.08s}
.cam-card:nth-child(n+3){animation-delay:.16s}
.cam-card:nth-child(n+4){animation-delay:.24s}
.cam-card.rec-on{border-color:var(--danger-d);box-shadow:0 0 28px rgba(255,34,85,.2)}
.cam-card.is-paused{border-color:rgba(255,170,0,.25);box-shadow:0 0 18px rgba(255,170,0,.1)}

.vid{position:relative;aspect-ratio:16/9;background:#080812;overflow:hidden}
.vid img{width:100%;height:100%;object-fit:cover;display:block}
.vid::after{
  content:'';position:absolute;inset:0;
  background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,0,0,.05) 2px,rgba(0,0,0,.05) 4px);
  pointer-events:none;
}

.pause-overlay{
  display:none;position:absolute;inset:0;z-index:3;
  background:rgba(5,5,16,.6);backdrop-filter:blur(4px);
  align-items:center;justify-content:center;flex-direction:column;gap:14px;
  cursor:pointer;
}
.pause-overlay.on{display:flex}
.pause-icon{
  width:72px;height:72px;display:flex;align-items:center;justify-content:center;
  border:3px solid rgba(255,170,0,.4);border-radius:50%;
  font-size:34px;color:var(--warn);background:rgba(255,170,0,.06);
  transition:all .3s;
}
.pause-overlay:hover .pause-icon{border-color:var(--warn);background:rgba(255,170,0,.12);transform:scale(1.08)}
.pause-label{
  font-family:'Syne',sans-serif;font-weight:700;font-size:16px;
  letter-spacing:.15em;text-transform:uppercase;color:var(--warn);
}

.ov-t{
  position:absolute;top:0;left:0;right:0;padding:14px 18px;
  display:flex;justify-content:space-between;align-items:center;
  background:linear-gradient(to bottom,rgba(0,0,0,.72),transparent);z-index:2;
}
.cam-nm{
  font-family:'Syne',sans-serif;font-weight:700;font-size:16px;
  letter-spacing:.06em;text-transform:uppercase;
  display:flex;align-items:center;gap:10px;
}
.sdot{width:10px;height:10px;border-radius:50%;background:var(--tx3);transition:all .3s}
.sdot.on{background:var(--accent);box-shadow:0 0 10px var(--accent);animation:pls-g 2s ease-in-out infinite}
.sdot.pau{background:var(--warn);box-shadow:0 0 10px var(--warn)}
.slbl{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--tx3);font-weight:500}
.slbl.on{color:var(--accent)}
.slbl.pau{color:var(--warn)}
.cam-err{font-size:11px;color:var(--warn);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rec-bdg{display:none;align-items:center;gap:6px;font-size:13px;font-weight:600;letter-spacing:.1em;color:var(--danger);text-transform:uppercase}
.rec-bdg.on{display:flex}
.rec-dot{width:10px;height:10px;background:var(--danger);border-radius:50%;animation:pls-r 1s ease-in-out infinite}

.ov-b{
  position:absolute;bottom:0;left:0;right:0;padding:16px 18px;
  display:flex;justify-content:center;gap:10px;flex-wrap:wrap;
  background:linear-gradient(to top,rgba(0,0,0,.85),transparent);z-index:2;
  opacity:0;transform:translateY(6px);transition:all .25s ease;
}
.cam-card:hover .ov-b{opacity:1;transform:translateY(0)}
.cbtn{
  display:flex;align-items:center;gap:7px;padding:10px 18px;
  background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.12);
  border-radius:8px;color:var(--tx);font-family:'DM Mono',monospace;
  font-size:14px;cursor:pointer;transition:all .2s;backdrop-filter:blur(8px);
}
.cbtn:hover{background:rgba(255,255,255,.15);border-color:rgba(255,255,255,.25)}
.cbtn.is-rec{background:rgba(255,34,85,.18);border-color:var(--danger);color:var(--danger)}
.cbtn.is-pau{background:rgba(255,170,0,.14);border-color:var(--warn);color:var(--warn)}
.cbtn.dng:hover{background:rgba(255,34,85,.15);border-color:var(--danger-d);color:var(--danger)}
.cbtn.edt:hover{background:rgba(0,170,255,.12);border-color:#0088cc;color:#44bbff}
.cbtn:disabled{opacity:.3;cursor:not-allowed!important}
.cbtn-i{font-size:17px}

.cam-inf{
  padding:12px 18px;display:flex;justify-content:space-between;align-items:center;
  border-top:1px solid var(--bdr);font-size:13px;color:var(--tx3);
}
.cam-url{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cam-up{color:var(--tx2)}

.welcome{
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  min-height:50vh;gap:18px;opacity:0;animation:fadeUp .6s ease .2s forwards;
}
.welcome-ico{font-size:60px;color:var(--bdr2)}
.welcome-txt{font-family:'Syne',sans-serif;font-size:24px;font-weight:600;color:var(--tx2)}
.welcome-hint{font-size:16px;color:var(--tx3);text-align:center;line-height:2.2}

/* ─── SIDEBAR ─── */
.sb{
  position:fixed;top:64px;right:0;bottom:0;width:440px;
  background:rgba(10,10,24,.97);backdrop-filter:blur(24px);
  border-left:1px solid var(--bdr);z-index:90;
  transform:translateX(100%);transition:transform .3s ease;
  overflow-y:auto;display:flex;flex-direction:column;
}
.sb.open{transform:translateX(0)}
.sb-sec{padding:24px;border-bottom:1px solid var(--bdr)}
.sb-ttl{
  font-family:'Syne',sans-serif;font-weight:700;font-size:15px;
  letter-spacing:.1em;text-transform:uppercase;color:var(--tx2);
  margin-bottom:16px;display:flex;align-items:center;gap:10px;
}
.sb-ttl-i{color:var(--accent);font-size:17px}

.fg{margin-bottom:14px}
.fl{display:block;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--tx3);margin-bottom:7px;font-weight:500}
.fi{
  width:100%;padding:12px 16px;background:var(--surf);border:1px solid var(--bdr);
  border-radius:var(--r);color:var(--tx);font-family:'DM Mono',monospace;
  font-size:15px;outline:none;transition:border-color .2s;
}
.fi:focus{border-color:var(--accent-d);box-shadow:0 0 0 3px rgba(0,255,136,.08)}
.fi::placeholder{color:var(--tx3);font-size:14px}
.fh{font-size:13px;color:var(--tx3);margin-top:10px;line-height:2}
.fh code{color:var(--accent-d);background:var(--surf);padding:2px 7px;border-radius:4px;font-size:12px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}

.btn-p{
  width:100%;padding:15px;
  background:linear-gradient(135deg,var(--accent-d),#00cc6a);
  border:none;border-radius:var(--r);color:#000;
  font-family:'Syne',sans-serif;font-weight:700;font-size:15px;
  letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:all .2s;
}
.btn-p:hover{background:linear-gradient(135deg,var(--accent),#00ff88);box-shadow:0 0 24px rgba(0,255,136,.3);transform:translateY(-1px)}
.btn-p.edit-mode{background:linear-gradient(135deg,var(--warn-d),var(--warn))}
.btn-p.edit-mode:hover{background:linear-gradient(135deg,var(--warn),#ffbb33);box-shadow:0 0 24px rgba(255,170,0,.3)}

.btn-cancel{
  width:100%;padding:10px;margin-top:8px;
  background:transparent;border:1px solid var(--bdr2);border-radius:var(--r);
  color:var(--tx2);font-family:'DM Mono',monospace;font-size:13px;
  cursor:pointer;transition:all .2s;
}
.btn-cancel:hover{border-color:var(--tx3);color:var(--tx)}

/* save status */
.save-bar{
  display:flex;align-items:center;justify-content:space-between;
  padding:12px 16px;background:var(--surf);border:1px solid var(--bdr);
  border-radius:var(--r);margin-bottom:14px;font-size:13px;
}
.save-bar-left{display:flex;align-items:center;gap:10px}
.save-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.save-dot.ok{background:var(--accent);box-shadow:0 0 6px var(--accent)}
.save-dot.need{background:var(--warn);box-shadow:0 0 6px var(--warn);animation:pulse-w 1.2s ease-in-out infinite}
.save-time{color:var(--tx3);font-size:12px}

/* history */
.hist-item{
  display:flex;align-items:center;justify-content:space-between;
  padding:12px 14px;background:var(--surf);border:1px solid var(--bdr);
  border-radius:var(--r);margin-bottom:8px;transition:border-color .2s;
}
.hist-item:hover{border-color:var(--bdr2)}
.hist-info{flex:1;min-width:0}
.hist-name{font-size:14px;color:var(--tx2);font-weight:500}
.hist-url{font-size:11px;color:var(--tx3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
.hist-time{font-size:11px;color:var(--tx3);margin-top:2px}
.hist-acts{display:flex;gap:6px;margin-left:10px;flex-shrink:0}

/* file lists */
.flist{display:flex;flex-direction:column;gap:10px;max-height:300px;overflow-y:auto}
.fitem{
  display:flex;align-items:center;justify-content:space-between;
  padding:12px 14px;background:var(--surf);border:1px solid var(--bdr);
  border-radius:var(--r);transition:border-color .2s;
}
.fitem:hover{border-color:var(--bdr2)}
.finfo{flex:1;min-width:0}
.fname{font-size:14px;color:var(--tx);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fmeta{font-size:12px;color:var(--tx3);margin-top:3px}
.facts{display:flex;gap:6px;margin-left:10px}
.btn-s{
  padding:6px 12px;background:var(--surf-l);border:1px solid var(--bdr);
  border-radius:6px;color:var(--tx2);font-family:'DM Mono',monospace;
  font-size:13px;cursor:pointer;transition:all .2s;
}
.btn-s:hover{background:var(--surf-h);color:var(--tx)}
.btn-s.dng:hover{background:rgba(255,34,85,.15);border-color:var(--danger-d);color:var(--danger)}
.btn-s.accent:hover{background:rgba(0,255,136,.1);border-color:var(--accent-d);color:var(--accent)}
.empty{text-align:center;padding:24px;color:var(--tx3);font-size:14px}

/* fullscreen */
.fs{
  display:none;position:fixed;inset:0;background:rgba(0,0,0,.96);z-index:200;
  align-items:center;justify-content:center;
}
.fs.on{display:flex}
.fs img{max-width:95vw;max-height:90vh;border-radius:var(--r)}
.fs-close{
  position:absolute;top:24px;right:24px;width:50px;height:50px;
  display:flex;align-items:center;justify-content:center;
  background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);
  border-radius:50%;color:#fff;font-size:26px;cursor:pointer;transition:all .2s;
}
.fs-close:hover{background:rgba(255,255,255,.18)}
.fs-name{
  position:absolute;top:28px;left:28px;
  font-family:'Syne',sans-serif;font-weight:700;font-size:20px;
  letter-spacing:.1em;text-transform:uppercase;color:#fff;
}

/* toast */
.toast-box{
  position:fixed;bottom:28px;right:28px;z-index:300;
  display:flex;flex-direction:column;gap:10px;pointer-events:none;
}
.toast{
  padding:14px 24px;background:var(--surf);border:1px solid var(--bdr);
  border-radius:var(--r);font-size:15px;color:var(--tx);
  animation:sInR .3s ease forwards;box-shadow:0 8px 32px rgba(0,0,0,.5);
  pointer-events:auto;max-width:420px;line-height:1.5;
}
.toast.ok{border-left:3px solid var(--accent)}
.toast.err{border-left:3px solid var(--danger)}
.toast.info{border-left:3px solid var(--warn)}
.toast.out{animation:sOutR .3s ease forwards}

@keyframes fadeUp{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}
@keyframes pls-g{0%,100%{opacity:1;box-shadow:0 0 4px var(--accent)}50%{opacity:.5;box-shadow:0 0 14px var(--accent)}}
@keyframes pls-r{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes pulse-w{0%,100%{opacity:1}50%{opacity:.4}}
@keyframes sInR{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:translateX(0)}}
@keyframes sOutR{from{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(40px)}}

@media(max-width:900px){
  .grid{grid-template-columns:1fr}
  .hdr-stats{display:none}
  .sb{width:100%}
  main.sb-open{margin-right:0}
}
@media(min-width:901px) and (max-width:1400px){
  .grid{grid-template-columns:repeat(auto-fill,minmax(420px,1fr))}
}
</style>
</head>
<body>

<header>
  <div class="logo"><div class="logo-dot"></div>CAMWATCH</div>
  <div class="hdr-stats">
    <div class="stat"><div class="stat-d"></div><span><span class="stat-v" id="sCon">0</span> 在线</span></div>
    <div class="stat"><div class="stat-d rec"></div><span><span class="stat-v" id="sRec">0</span> 录像</span></div>
    <div class="stat"><div class="stat-d pau"></div><span><span class="stat-v" id="sPau">0</span> 暂停</span></div>
    <div class="stat"><div class="stat-d hist"></div><span><span class="stat-v" id="sHist">0</span> 历史</span></div>
    <div class="stat"><span>存储 <span class="stat-v" id="sStor">0</span> MB</span></div>
  </div>
  <div class="hdr-acts">
    <button class="ibtn" onclick="manualSave()" title="手动保存配置">&#128190;</button>
    <button class="ibtn" onclick="toggleSB()" title="控制面板">&#9776;</button>
  </div>
</header>

<main id="main">
  <div class="grid" id="grid"></div>
</main>

<aside class="sb" id="sb">
  <!-- 配置状态 -->
  <div class="sb-sec">
    <div class="sb-ttl"><span class="sb-ttl-i">&#128190;</span> 配置状态</div>
    <div class="save-bar">
      <div class="save-bar-left">
        <div class="save-dot ok" id="saveDot"></div>
        <span id="saveTxt">已保存</span>
      </div>
      <span class="save-time" id="saveTime">--</span>
    </div>
  </div>

  <!-- 添加 / 编辑摄像头 -->
  <div class="sb-sec">
    <div class="sb-ttl"><span class="sb-ttl-i" id="formIcon">+</span> <span id="formTitle">添加摄像头</span></div>
    <div class="fg">
      <label class="fl">摄像头名称</label>
      <input class="fi" id="iName" placeholder="例:前门摄像头">
    </div>
    <div class="fg">
      <label class="fl">连接地址</label>
      <input class="fi" id="iUrl" placeholder="rtsp://192.168.1.64:554/Streaming/Channels/101">
    </div>
    <div class="row2">
      <div class="fg">
        <label class="fl">用户名</label>
        <input class="fi" id="iUser" placeholder="admin" autocomplete="off">
      </div>
      <div class="fg">
        <label class="fl">密码</label>
        <input class="fi" id="iPwd" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="off">
      </div>
    </div>
    <button class="btn-p" id="addBtn" onclick="submitCam()">连接摄像头</button>
    <button class="btn-cancel" id="cancelBtn" style="display:none" onclick="cancelEdit()">取消编辑</button>
    <div class="fh" id="formHelp">
      海康: <code>rtsp://IP:554/Streaming/Channels/101</code><br>
      TP-LINK: <code>rtsp://IP:554/stream1</code><br>
      大华: <code>rtsp://IP:554/cam/realmonitor?channel=1&subtype=0</code><br>
      USB: 地址填 <code>0</code>
    </div>
  </div>

  <!-- 历史摄像头 -->
  <div class="sb-sec">
    <div class="sb-ttl"><span class="sb-ttl-i">&#128336;</span> 历史记录</div>
    <div class="flist" id="histList"><div class="empty">暂无历史</div></div>
  </div>

  <!-- 录像文件 -->
  <div class="sb-sec">
    <div class="sb-ttl"><span class="sb-ttl-i">&#9679;</span> 录像文件</div>
    <div class="flist" id="recList"><div class="empty">暂无录像</div></div>
  </div>

  <!-- 截图文件 -->
  <div class="sb-sec">
    <div class="sb-ttl"><span class="sb-ttl-i">&#9670;</span> 截图文件</div>
    <div class="flist" id="snapList"><div class="empty">暂无截图</div></div>
  </div>
</aside>

<div class="fs" id="fsModal">
  <div class="fs-name" id="fsName"></div>
  <button class="fs-close" onclick="closeFs()">&#10005;</button>
  <img id="fsImg" src="">
</div>

<div class="toast-box" id="toasts"></div>

<script>
const S = { cams: [], history: [], recs: [], snaps: [], sb: false, editingId: null };

async function api(m, u, b) {
  const o = { method: m };
  if (b) { o.headers = { 'Content-Type': 'application/json' }; o.body = JSON.stringify(b); }
  const r = await fetch(u, o);
  if (!r.ok) {
    let msg = 'HTTP ' + r.status;
    try { const j = await r.json(); msg = j.detail || msg; } catch {}
    throw new Error(msg);
  }
  return r.json();
}

function toast(msg, type = 'ok') {
  const c = document.getElementById('toasts');
  const el = document.createElement('div');
  el.className = 'toast ' + type;
  el.textContent = msg;
  c.appendChild(el);
  setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 300); }, 4000);
}

function esc(s) {
  return String(s).replace(/&/g,'&amp;').replace(/'/g,"\\'").replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

function toggleSB() {
  S.sb = !S.sb;
  document.getElementById('sb').classList.toggle('open', S.sb);
  document.getElementById('main').classList.toggle('sb-open', S.sb);
}

/* ━━━━━━━ CAMERA GRID ━━━━━━━ */
async function loadCams() {
  try { S.cams = await api('GET', '/api/cameras'); renderGrid(); updateStats(); }
  catch (e) { console.error(e); }
}

function renderGrid() {
  const g = document.getElementById('grid');
  if (S.cams.length === 0) {
    g.innerHTML = `
      <div class="welcome" style="grid-column:1/-1">
        <div class="welcome-ico">&#128247;</div>
        <div class="welcome-txt">尚无摄像头</div>
        <div class="welcome-hint">
          点击右上角 &#9776; 打开控制面板<br>
          添加局域网摄像头即可开始监控<br>
          删除的摄像头会保留在历史记录中
        </div>
      </div>`;
    return;
  }

  g.innerHTML = S.cams.map((c, i) => {
    const st = c.paused ? 'PAUSED' : (c.connected ? 'LIVE' : 'OFFLINE');
    const dc = c.paused ? 'pau' : (c.connected ? 'on' : '');
    const lc = c.paused ? 'pau' : (c.connected ? 'on' : '');

    return `
    <div class="cam-card ${c.recording?'rec-on':''} ${c.paused?'is-paused':''}" style="animation-delay:${i*.08}s">
      <div class="vid">
        <img src="/api/stream/${c.id}" alt="${c.name}">
        <div class="pause-overlay ${c.paused?'on':''}" onclick="togglePause('${c.id}')" title="点击恢复">
          <div class="pause-icon">&#9654;</div>
          <div class="pause-label">已暂停 · 点击恢复</div>
        </div>
        <div class="ov-t">
          <div class="cam-nm"><div class="sdot ${dc}"></div>${c.name}</div>
          <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
            <div style="display:flex;align-items:center;gap:12px">
              <span class="slbl ${lc}">${st}</span>
              <div class="rec-bdg ${c.recording?'on':''}"><div class="rec-dot"></div>REC</div>
            </div>
            ${c.error&&!c.paused?'<div class="cam-err" title="'+esc(c.error)+'">'+esc(c.error)+'</div>':''}
          </div>
        </div>
        <div class="ov-b">
          <button class="cbtn ${c.paused?'is-pau':''}" onclick="togglePause('${c.id}')">
            <span class="cbtn-i">${c.paused?'&#9654;':'&#9646;&#9646;'}</span>${c.paused?'恢复':'暂停'}
          </button>
          <button class="cbtn ${c.recording?'is-rec':''}" onclick="toggleRec('${c.id}',${c.recording})" ${c.paused?'disabled':''}>
            <span class="cbtn-i">${c.recording?'&#9632;':'&#9679;'}</span>${c.recording?'停止':'录像'}
          </button>
          <button class="cbtn" onclick="snap('${c.id}')" ${c.paused?'disabled':''}>
            <span class="cbtn-i">&#9112;</span>截图
          </button>
          <button class="cbtn" onclick="openFs('${c.id}','${esc(c.name)}')">
            <span class="cbtn-i">&#9974;</span>全屏
          </button>
          <button class="cbtn edt" onclick="editCam('${c.id}')">
            <span class="cbtn-i">&#9998;</span>编辑
          </button>
          <button class="cbtn dng" onclick="rmCam('${c.id}','${esc(c.name)}')">
            <span class="cbtn-i">&#10005;</span>移除
          </button>
        </div>
      </div>
      <div class="cam-inf">
        <span class="cam-url" title="${c.url}">${c.url}</span>
        <span class="cam-up">${c.uptime||(c.paused?'已暂停':'--')}</span>
      </div>
    </div>`;
  }).join('');
}

/* ━━━━━━━ ACTIONS ━━━━━━━ */

function submitCam() {
  if (S.editingId) { updateCam(); } else { addCam(); }
}

async function addCam() {
  const n = document.getElementById('iName').value.trim();
  const u = document.getElementById('iUrl').value.trim();
  const user = document.getElementById('iUser').value.trim();
  const pwd = document.getElementById('iPwd').value;
  if (!n || !u) { toast('请填写名称和地址', 'err'); return; }
  try {
    await api('POST', '/api/cameras', { name: n, url: u, user, pwd });
    clearForm();
    toast('"' + n + '" 正在连接 · 已保存');
    loadCams(); loadHistory();
  } catch (e) { toast('添加失败: ' + e.message, 'err'); }
}

async function updateCam() {
  const n = document.getElementById('iName').value.trim();
  const u = document.getElementById('iUrl').value.trim();
  const user = document.getElementById('iUser').value.trim();
  const pwd = document.getElementById('iPwd').value;
  if (!n || !u) { toast('请填写名称和地址', 'err'); return; }
  try {
    await api('PUT', '/api/cameras/' + S.editingId, { name: n, url: u, user, pwd });
    cancelEdit();
    toast('"' + n + '" 已更新 · 已保存');
    loadCams();
  } catch (e) { toast('更新失败: ' + e.message, 'err'); }
}

function editCam(id) {
  const cam = S.cams.find(c => c.id === id);
  if (!cam) return;
  S.editingId = id;
  document.getElementById('iName').value = cam.name;
  document.getElementById('iUrl').value = cam.url;
  document.getElementById('iUser').value = cam.user || '';
  document.getElementById('iPwd').value = cam.pwd || '';
  updateFormUI();
  if (!S.sb) toggleSB();
  document.getElementById('iName').focus();
}

function editHistory(id) {
  const h = S.history.find(c => c.id === id);
  if (!h) return;
  S.editingId = null; // new camera mode, not update
  document.getElementById('iName').value = h.name;
  document.getElementById('iUrl').value = h.url;
  document.getElementById('iUser').value = h.user || '';
  document.getElementById('iPwd').value = h.pwd || '';
  updateFormUI();
  if (!S.sb) toggleSB();
  document.getElementById('iName').focus();
  toast('已填入历史摄像头信息,可修改后连接', 'info');
}

function cancelEdit() {
  S.editingId = null;
  clearForm();
  updateFormUI();
}

function clearForm() {
  document.getElementById('iName').value = '';
  document.getElementById('iUrl').value = '';
  document.getElementById('iUser').value = '';
  document.getElementById('iPwd').value = '';
}

function updateFormUI() {
  const btn = document.getElementById('addBtn');
  const cancel = document.getElementById('cancelBtn');
  const icon = document.getElementById('formIcon');
  const title = document.getElementById('formTitle');
  if (S.editingId) {
    btn.textContent = '更新摄像头';
    btn.className = 'btn-p edit-mode';
    cancel.style.display = 'block';
    icon.textContent = '✎';
    title.textContent = '编辑摄像头';
  } else {
    btn.textContent = '连接摄像头';
    btn.className = 'btn-p';
    cancel.style.display = 'none';
    icon.textContent = '+';
    title.textContent = '添加摄像头';
  }
}

async function rmCam(id, name) {
  if (!confirm('移除 "' + name + '" ?\n将保留在历史记录中,可随时恢复。')) return;
  try {
    await api('DELETE', '/api/cameras/' + id);
    if (S.editingId === id) cancelEdit();
    toast('"' + name + '" 已移至历史记录');
    loadCams(); loadHistory(); updateStats();
  } catch (e) { toast('移除失败', 'err'); }
}

async function togglePause(id) {
  try {
    const r = await api('POST', '/api/cameras/' + id + '/pause');
    toast(r.paused ? '已暂停' : '正在恢复连接...', r.paused ? 'info' : 'ok');
    loadCams(); updateStats();
  } catch (e) { toast('操作失败: ' + e.message, 'err'); }
}

async function toggleRec(id, on) {
  try {
    await api('POST', '/api/cameras/' + id + '/record/' + (on ? 'stop' : 'start'));
    toast(on ? '录像已停止' : '开始录像');
    loadCams(); loadRecs();
  } catch (e) { toast(e.message || '操作失败', 'err'); }
}

async function snap(id) {
  try {
    const r = await api('POST', '/api/cameras/' + id + '/snapshot');
    toast('截图已保存: ' + r.filename);
    loadSnaps();
  } catch (e) { toast('截图失败: ' + (e.message || '无信号'), 'err'); }
}

function openFs(id, name) {
  document.getElementById('fsName').textContent = name;
  document.getElementById('fsImg').src = '/api/stream/' + id;
  document.getElementById('fsModal').classList.add('on');
}
function closeFs() {
  document.getElementById('fsModal').classList.remove('on');
  document.getElementById('fsImg').src = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeFs(); });

/* ━━━━━━━ HISTORY ━━━━━━━ */
async function loadHistory() {
  try { S.history = await api('GET', '/api/history'); renderHistory(); }
  catch {}
}

function renderHistory() {
  const el = document.getElementById('histList');
  if (!S.history.length) { el.innerHTML = '<div class="empty">暂无历史记录</div>'; return; }
  el.innerHTML = S.history.map(h => `
    <div class="hist-item">
      <div class="hist-info">
        <div class="hist-name">${esc(h.name)}</div>
        <div class="hist-url" title="${h.url}">${h.url}</div>
        <div class="hist-time">删除于 ${h.deleted_at || '--'}</div>
      </div>
      <div class="hist-acts">
        <button class="btn-s accent" onclick="restoreCam('${h.id}')" title="恢复">恢复</button>
        <button class="btn-s" onclick="editHistory('${h.id}')" title="编辑后重新添加">编辑</button>
        <button class="btn-s dng" onclick="delHistory('${h.id}')" title="永久删除">&#10005;</button>
      </div>
    </div>
  `).join('');
}

async function restoreCam(id) {
  try {
    const r = await api('POST', '/api/history/' + id + '/restore');
    toast('已恢复 "' + r.name + '"');
    loadCams(); loadHistory(); updateStats();
  } catch (e) { toast('恢复失败: ' + e.message, 'err'); }
}

async function delHistory(id) {
  if (!confirm('永久删除此记录?删除后无法恢复。')) return;
  try {
    await api('DELETE', '/api/history/' + id);
    toast('已永久删除');
    loadHistory(); updateStats();
  } catch (e) { toast('删除失败', 'err'); }
}

/* ━━━━━━━ SAVE ━━━━━━━ */
async function manualSave() {
  try {
    const r = await api('POST', '/api/config/save');
    toast('已保存 (' + r.cameras + ' 摄像头, ' + r.history + ' 历史)');
    updateStats();
  } catch (e) { toast('保存失败', 'err'); }
}

function updateSaveUI(sys) {
  const dot = document.getElementById('saveDot');
  const txt = document.getElementById('saveTxt');
  const time = document.getElementById('saveTime');
  if (sys.config_saved) {
    dot.className = 'save-dot ok';
    txt.textContent = '已保存';
    time.textContent = sys.config_time || '';
  } else {
    dot.className = 'save-dot need';
    txt.textContent = '未保存';
    time.textContent = '';
  }
}

/* ━━━━━━━ FILES ━━━━━━━ */
async function loadRecs() {
  try { S.recs = await api('GET', '/api/recordings'); renderRecs(); } catch {}
}
function renderRecs() {
  const el = document.getElementById('recList');
  if (!S.recs.length) { el.innerHTML = '<div class="empty">暂无录像</div>'; return; }
  el.innerHTML = S.recs.map(r => `
    <div class="fitem">
      <div class="finfo"><div class="fname">${r.name}</div><div class="fmeta">${r.size} · ${r.date}</div></div>
      <div class="facts">
        <button class="btn-s" onclick="window.open('/api/recordings/${r.name}/download')">&#8595;</button>
        <button class="btn-s dng" onclick="delRec('${r.name}')">&#10005;</button>
      </div>
    </div>
  `).join('');
}
async function delRec(name) {
  if (!confirm('删除 "' + name + '" ?')) return;
  try { await api('DELETE', '/api/recordings/' + name); toast('已删除'); loadRecs(); }
  catch (e) { toast('删除失败', 'err'); }
}

async function loadSnaps() {
  try { S.snaps = await api('GET', '/api/snapshots'); renderSnaps(); } catch {}
}
function renderSnaps() {
  const el = document.getElementById('snapList');
  if (!S.snaps.length) { el.innerHTML = '<div class="empty">暂无截图</div>'; return; }
  el.innerHTML = S.snaps.map(s => `
    <div class="fitem">
      <div class="finfo"><div class="fname">${s.name}</div><div class="fmeta">${s.size} · ${s.date}</div></div>
      <div class="facts">
        <button class="btn-s" onclick="window.open('/api/snapshots/${s.name}')">查看</button>
        <button class="btn-s dng" onclick="delSnap('${s.name}')">&#10005;</button>
      </div>
    </div>
  `).join('');
}
async function delSnap(name) {
  if (!confirm('删除此截图?')) return;
  try { await api('DELETE', '/api/snapshots/' + name); toast('已删除'); loadSnaps(); }
  catch (e) { toast('删除失败', 'err'); }
}

/* ━━━━━━━ STATS ━━━━━━━ */
async function updateStats() {
  try {
    const d = await api('GET', '/api/system');
    document.getElementById('sCon').textContent = d.connected;
    document.getElementById('sRec').textContent = d.recording;
    document.getElementById('sPau').textContent = d.paused;
    document.getElementById('sHist').textContent = d.history;
    document.getElementById('sStor').textContent = d.storage_mb;
    updateSaveUI(d);
  } catch {}
}

/* ━━━━━━━ INIT ━━━━━━━ */
(async function init() {
  await loadCams();
  await loadHistory();
  await loadRecs();
  await loadSnaps();
  setInterval(() => { loadCams(); updateStats(); }, 5000);
  setInterval(() => { loadHistory(); loadRecs(); loadSnaps(); }, 10000);
})();
</script>
</body>
</html>
相关推荐
feasibility.1 小时前
反爬十层妖塔:现代爬虫攻防的立体战争
爬虫·python·科技·scrapy·rust·go·硬件
十八旬1 小时前
快速安装ClaudeCode完整指南
开发语言·windows·python·claude
dFObBIMmai2 小时前
如何在 CSS 中实现元素的绝对定位,使其不受窗口尺寸变化影响
jvm·数据库·python
巴巴博一2 小时前
2026 最新:Trae / Cursor 一键接入 taste-skill 完整教程(让 AI 前端告别“AI 味”)
前端·ai·ai编程
kyriewen2 小时前
半夜三点线上崩了,AI替我背了锅——用AI排错,五分钟定位三年老bug
前端·javascript·ai编程
WL_Aurora2 小时前
Python 算法基础篇之集合
python·算法
kyriewen2 小时前
我让 AI 当了 24 小时全年无休的“毒舌考官”
前端·ci/cd·ai编程
hexu_blog3 小时前
vue+java实现图片批量压缩
java·前端·vue.js
头歌实践平台3 小时前
招聘大数据可视化
大数据·python