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/ # 截图文件存储(自动创建)
快速开始
- 安装依赖
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 第二路 |
重要:海康摄像头需关闭"安全模式"
-
浏览器打开
http://<摄像头IP> -
登录(用户名
admin,密码为机身验证码) -
进入 配置 → 系统 → 安全管理 → 安全模式
-
改为 开放模式,保存
部分固件路径为:配置 → 网络 → 高级配置 → 集成协议
TP-LINK (普联)
RTSP 地址格式:
地址: rtsp://<IP>:554/stream1
用户名: admin
密码: 在 APP 或网页端设置的密码
重要:TP-LINK 需手动开启 RTSP
-
浏览器打开
http://<摄像头IP> -
登录
-
进入 设置 → 网络 → 高级设置 → 服务
-
勾选 RTSP 服务,保存
大华 (Dahua)
RTSP 地址格式:
地址: rtsp://<IP>:554/cam/realmonitor?channel=1&subtype=0
用户名: admin
密码: 设备密码
| 参数 | 说明 |
|---|---|
channel=1 |
通道1 |
subtype=0 |
主码流(高清) |
subtype=1 |
子码流(标清) |
USB 摄像头
地址: 0
用户名: (留空)
密码: (留空)
多个 USB 摄像头依次使用
0、1、2作为地址。
其他品牌 / 通用格式
| 品牌 | 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 播放器 测试:
-
下载安装 VLC
-
打开 VLC → 媒体 → 打开网络串流
-
输入完整 RTSP 地址(含用户名密码):
rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101
- 能看到画面 → 在 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
代码:
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="手动保存配置">💾</button>
<button class="ibtn" onclick="toggleSB()" title="控制面板">☰</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">💾</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="••••••" 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">🕐</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">●</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">◆</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()">✕</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,'&').replace(/'/g,"\\'").replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>');
}
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">📷</div>
<div class="welcome-txt">尚无摄像头</div>
<div class="welcome-hint">
点击右上角 ☰ 打开控制面板<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">▶</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?'▶':'▮▮'}</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?'■':'●'}</span>${c.recording?'停止':'录像'}
</button>
<button class="cbtn" onclick="snap('${c.id}')" ${c.paused?'disabled':''}>
<span class="cbtn-i">⎘</span>截图
</button>
<button class="cbtn" onclick="openFs('${c.id}','${esc(c.name)}')">
<span class="cbtn-i">⛶</span>全屏
</button>
<button class="cbtn edt" onclick="editCam('${c.id}')">
<span class="cbtn-i">✎</span>编辑
</button>
<button class="cbtn dng" onclick="rmCam('${c.id}','${esc(c.name)}')">
<span class="cbtn-i">✕</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="永久删除">✕</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')">↓</button>
<button class="btn-s dng" onclick="delRec('${r.name}')">✕</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}')">✕</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>