diff --git a/backend/app/config.py b/backend/app/config.py index ff4f8e7..311d08c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -39,6 +39,10 @@ class Settings: DATA_FILE = Path(_env("DPM_DATA_FILE", str(BASE_DIR / "data.json"))) DIST_DIR = Path(_env("DPM_DIST_DIR", str(BASE_DIR.parent / "dist"))) # 前端构建产物(可选托管) + # ---- 视频帧保存(摄像头识别帧,仅检测到人脸/手势时落盘,便于排查) ---- + SAVE_VISION_FRAMES = _env("DPM_SAVE_VISION_FRAMES", "1") == "1" + VISION_FRAME_SAVE_DIR = Path(_env("DPM_VISION_SAVE_DIR", str(MEDIA_DIR / "video"))) + # ---- MQTT(优先使用 .env 中的局域网 Broker 配置) ---- MQTT_ENABLED = _env("DPM_MQTT_ENABLED", "1") == "1" MQTT_HOST = _env("DPM_MQTT_HOST", _env("MQTT_BROKER_HOST", "192.168.1.9")) diff --git a/backend/app/main.py b/backend/app/main.py index f5e659e..486e9a4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -108,8 +108,8 @@ app.add_api_route("/admin/logout", admin_logout, methods=["GET"], include_in_sch _DOWNLOAD_ROOT = Path(__file__).resolve().parent.parent # backend/ -@app.get("/download", include_in_schema=False) -@app.get("/11", include_in_schema=False) +@app.get("/download") +@app.get("/11") async def download_installer(): """下载 Windows 客户端安装包(backend/ 下 *.setup.exe,如 云超服..._x64-setup.exe)。""" for f in sorted(_DOWNLOAD_ROOT.glob("*setup.exe")): @@ -146,7 +146,7 @@ class UpdateCheckBody(BaseModel): current: str = "" # 前端上报的当前版本,如 0.1.0 -@app.post("/api/update/check", include_in_schema=False) +@app.post("/api/update/check") async def update_check(body: UpdateCheckBody): """检测更新:返回最新版本号、安装包下载地址、是否需要更新(仅 Windows)。""" inst = _latest_installer() @@ -171,17 +171,7 @@ async def update_check(body: UpdateCheckBody): # ---------- 前端静态托管 + SPA 回退(浏览器直接访问 :8000 即可) ---------- @app.get("/{path:path}", include_in_schema=False) -async def spa_fallback(path: str): - if not DIST.exists(): - return JSONResponse({"detail": "前端未构建(dist 目录不存在)"}, status_code=404) - dist = DIST.resolve() - if not path: - target = dist / "index.html" - else: - target = (dist / path).resolve() - if target.is_file() and target.is_relative_to(dist): - return FileResponse(target) - index = dist / "index.html" - if index.exists(): - return FileResponse(index) - return JSONResponse({"detail": "Not Found"}, status_code=404) +async def spa_fallback(request: Request): + """默认导向管理后台:屏蔽通过后端地址直接访问大屏前端(含首页 / 及 /twin /ai /screen 等)。 + /api、/admin、/static、/file 等已在更早路由/挂载处理,不会走到这里。""" + return RedirectResponse(url="/admin", status_code=307) diff --git a/backend/app/vision_yolo.py b/backend/app/vision_yolo.py index 51cec0b..a33d0b0 100644 --- a/backend/app/vision_yolo.py +++ b/backend/app/vision_yolo.py @@ -19,8 +19,12 @@ from pathlib import Path from PIL import Image +from .config import settings + log = logging.getLogger("dpm.vision") +_frame_seq = 0 + MODEL_DIR = Path(__file__).resolve().parent.parent / "models" FACE_MODEL_PATH = MODEL_DIR / "yolov8n-face.pt" POSE_MODEL_PATH = MODEL_DIR / "yolov8n-pose.pt" @@ -188,6 +192,40 @@ def predict_jpeg(jpeg_bytes: bytes): } +def _should_save(data: dict) -> bool: + """仅当画面检测到人脸/手势时才值得落盘保存。""" + return ( + int(data.get("faces") or 0) > 0 + or bool(data.get("raised")) + or bool(data.get("fists")) + or bool(data.get("both_up")) + or bool(data.get("pointing")) + or bool(data.get("hands_close")) + ) + + +def maybe_save_frame(jpeg: bytes, data: dict): + """仅将命中(有人/有动作)的 JPEG 帧落盘到 MEDIA_DIR/video/<小时目录>/,便于排查识别链路。 + 目录按小时分割(YYYYMMDD_HH),不自动清理。 + 可通过 DPM_SAVE_VISION_FRAMES=0 关闭,或 DPM_VISION_SAVE_DIR 改目录。""" + global _frame_seq + if not settings.SAVE_VISION_FRAMES: + return + if not _should_save(data): + return + _frame_seq += 1 + ts = time.strftime("%Y%m%d_%H%M%S") + hour = time.strftime("%Y%m%d_%H") + try: + save_dir = settings.VISION_FRAME_SAVE_DIR / hour + save_dir.mkdir(parents=True, exist_ok=True) + path = save_dir / f"{ts}_{_frame_seq:05d}.jpg" + with open(path, "wb") as f: + f.write(jpeg) + except Exception as e: # noqa: BLE001 + log.warning("vision: 保存帧失败 %s", e) + + def predict_base64(b64: str): """入口:base64 JPEG → 检测结果 dict(含 ok 标记,失败时带 error)""" try: @@ -198,6 +236,7 @@ def predict_base64(b64: str): try: data = predict_jpeg(jpeg) data["ok"] = True + maybe_save_frame(jpeg, data) return data except Exception as e: # noqa: BLE001 log.error("vision: 推理失败 %s", e, exc_info=True) diff --git a/backend/static/admin.css b/backend/static/admin.css index 4188d79..d04281d 100644 --- a/backend/static/admin.css +++ b/backend/static/admin.css @@ -1265,3 +1265,128 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra font-size: 13px; color: #1d2c44; outline: none; } .mqtt-input:focus { border-color: #2f6bff; } + +/* ============ 弱化连接状态(沉底、紧凑、低调) ============ */ +.conn-card { opacity: 0.88; } +.conn-card .card-header { padding-bottom: 6px; } +.conn-card .conn-status { display: flex; flex-wrap: wrap; gap: 6px 22px; } +.conn-card .conn-row { min-width: 150px; } +.conn-card .conn-label { font-size: 11px; } + +/* ============ 大屏控制:mqtt-group 按操作优先级排序 ============ */ +.mqtt-card { display: flex; flex-direction: column; } +.mqtt-card .mqtt-group { order: 50; } /* 未显式设置 order 的组默认靠后 */ + +/* ============ 手机适配(响应式) ============ */ +@media (max-width: 900px) { + .main-grid { grid-template-columns: 1fr; padding: 16px; gap: 16px; } +} +@media (max-width: 600px) { + .main-grid { padding: 10px; gap: 12px; } + .topbar { flex-wrap: wrap; row-gap: 8px; } + .topbar-center { order: 3; width: 100%; justify-content: flex-start; } + .page-switch-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; } + .mqtt-row { flex-wrap: wrap; gap: 8px; } + .page-switch-btn { flex: 1 1 45%; } + .form-inline { flex-direction: column; } + .form-row { flex-wrap: wrap; } + .conn-card .conn-status { flex-direction: column; gap: 6px; } + .card { padding: 16px; } +} + +/* ============ 遥控器式快捷控制条 ============ */ +.remote-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 18px; + max-width: 1400px; + margin: 16px auto 0; + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); +} +.remote-group { display: flex; gap: 6px; } +.remote-btn { + width: 42px; height: 42px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 24px; line-height: 1; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: #fff; color: var(--text); + cursor: pointer; + transition: var(--transition); +} +.remote-btn:hover { background: var(--primary-subtle); border-color: var(--primary); color: var(--primary); } +.remote-target { + display: flex; align-items: center; gap: 8px; + padding: 5px 12px; + background: var(--primary-subtle); + border: 1px solid rgba(0,189,125,.2); + border-radius: var(--radius-md); +} +.remote-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); white-space: nowrap; } +.remote-count { font-size: 11px; color: var(--text-dim); white-space: nowrap; } +.remote-target select { + min-width: 160px; + padding: 6px 10px; + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: #fff; color: var(--text); font-size: 13px; +} +.remote-pages { display: flex; gap: 6px; flex-wrap: wrap; } + +@media (max-width: 600px) { + .remote-bar { margin: 10px; padding: 10px; } + .remote-target { flex: 1 1 100%; } + .remote-target select { flex: 1; min-width: 0; } +} + +/* 遥控条:页面切换合并模块 + 分隔线 */ +.remote-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; } +.remote-sep { width: 1px; height: 26px; background: var(--border-default); margin: 0 4px; } +@media (max-width: 600px) { + .remote-nav { width: 100%; } + .remote-sep { display: none; } +} + +/* ============ 顶部控制模块(选择屏幕 / 页面切换,独立上下排列) ============ */ +.control-stack { + max-width: 1400px; + margin: 16px auto 0; + padding: 0 32px; + display: flex; + flex-direction: column; + gap: 24px; +} +.control-card { margin: 0; } +@media (max-width: 600px) { + .control-stack { padding: 0 10px; } +} + +/* 页面切换:左右箭头独立一行,与下方页面按钮分隔开 */ +.page-nav-prev { + display: flex; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-subtle); +} +.page-nav-links { padding-top: 2px; } +@media (max-width: 600px) { + .page-nav-prev { justify-content: center; } + .page-nav-links { justify-content: flex-start; } +} + +/* 卡片图标:继承主题主色(appica color -> currentColor) */ +.card-header .card-icon { color: var(--primary); } + +/* 页面按钮:当前激活页高亮 */ +.page-switch-btn.active { + background: var(--primary); + border-color: var(--primary); + color: #fff; + box-shadow: 0 2px 8px rgba(0,189,125,0.35); +} diff --git a/backend/static/admin.js b/backend/static/admin.js index 5bd9e9f..f863ca4 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -14,6 +14,20 @@ var screenId = ''; var screenRole = ''; + /* 状态识别:点击某页面后,把该页操作模块前置到页面控制下方,并高亮当前页面按钮 */ + function activatePage(page) { + if (!page) return; + var cards = document.querySelectorAll('.main-grid .card[data-page]'); + for (var i = 0; i < cards.length; i++) cards[i].style.order = ''; + var active = null; + for (var j = 0; j < cards.length; j++) { + if (cards[j].getAttribute('data-page') === page) { active = cards[j]; break; } + } + if (active) active.style.order = -10; // 前置到 main-grid 顶部(页面控制下方) + var navs = document.querySelectorAll('[data-nav]'); + for (var k = 0; k < navs.length; k++) navs[k].classList.toggle('active', navs[k].getAttribute('data-nav') === page); + } + function applyScreenTarget(sel) { sel = sel || document.getElementById('screenTarget'); if (!sel) return; @@ -179,6 +193,8 @@ try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; } // 屏幕终端精准控制:除「双屏开关」外,注入目标 client_id + role;全空=全局广播 if (cmd.action !== 'dual_screen') { cmd.screen_id = screenId; cmd.screen_role = screenRole; } + // 状态识别:切页命令 → 前置对应页面操作模块 + if (cmd.action === 'navigate' && cmd.params && cmd.params.page) activatePage(cmd.params.page); var tgtTxt = screenId ? (cmd.screen_role === 'main' ? '主屏' : cmd.screen_role === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10) : '全部'; @@ -212,6 +228,7 @@ // 页面切换(绑定目标屏幕:screen_id 精确某台;空=全部) if (btn.hasAttribute('data-nav')) { var page = btn.getAttribute('data-nav'); + activatePage(page); // 状态识别:前置该页操作模块 var tgtTxt = screenId ? (screenRole === 'main' ? '主屏' : screenRole === 'secondary' ? '副屏' : '屏幕') + '·' + String(screenId).slice(0, 10) : '全部'; diff --git a/backend/templates/admin.html b/backend/templates/admin.html index b06f61a..9fee5e0 100644 --- a/backend/templates/admin.html +++ b/backend/templates/admin.html @@ -1,158 +1,246 @@ + - - -管理后台 · 昆明市大学生创业园 - + + + 管理后台 · 昆明市大学生创业园 + + -{% if not logged_in %} - -
-
-