refactor(park-desktop): admin 后台新 UI + vision_yolo 手势接入,清理设计文档与安装包,补充工作区规范
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# OPC 智能园区后端(FastAPI + MQTT)
|
||||
|
||||
> 📍 工作区位置:`code/dpm/backend/`(DPM 园区大屏后端,独立仓库)。权威说明以本文档为准;系统/运维文档见 `design/dpm/`,前端见 `code/dpm/src/`。
|
||||
|
||||
独立后端服务:**所有数据通过 REST API 提供**,**页面/媒体控制通过 MQTT 下发**,媒体资源由后端统一存储与返回。
|
||||
|
||||
## 架构
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+5
-15
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
: '全部';
|
||||
|
||||
+432
-325
@@ -1,158 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 · 昆明市大学生创业园</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理后台 · 昆明市大学生创业园</title>
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if not logged_in %}
|
||||
<!-- ==================== 登录页 ==================== -->
|
||||
<div class="admin-page">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="icon">
|
||||
<div class="iso-top"></div>
|
||||
<div class="iso-left"></div>
|
||||
<div class="iso-right"></div>
|
||||
<div class="iso-dot"></div>
|
||||
{% if not logged_in %}
|
||||
<!-- ==================== 登录页 ==================== -->
|
||||
<div class="admin-page">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<div class="icon">
|
||||
<div class="iso-top"></div>
|
||||
<div class="iso-left"></div>
|
||||
<div class="iso-right"></div>
|
||||
<div class="iso-dot"></div>
|
||||
</div>
|
||||
<h2>昆明市大学生创业园</h2>
|
||||
<p>大屏幕轮播控制系统</p>
|
||||
</div>
|
||||
<h2>昆明市大学生创业园</h2>
|
||||
<p>大屏幕轮播控制系统</p>
|
||||
{% if error %}
|
||||
<div class="toast error" id="loginError" style="position:static;margin-bottom:12px;">账号或密码错误,请重试</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login">
|
||||
<div class="field">
|
||||
<label>账号</label>
|
||||
<input name="username" placeholder="请输入账号" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="toast error" id="loginError" style="position:static;margin-bottom:12px;">账号或密码错误,请重试</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login">
|
||||
<div class="field">
|
||||
<label>账号</label>
|
||||
<input name="username" placeholder="请输入账号" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- ==================== 管理台 ==================== -->
|
||||
<div class="admin-page">
|
||||
<div class="dashboard active">
|
||||
{% else %}
|
||||
<!-- ==================== 管理台 ==================== -->
|
||||
<div class="admin-page">
|
||||
<div class="dashboard active">
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand-dot"></div>
|
||||
<h1>昆明市大学生创业园</h1>
|
||||
</div>
|
||||
<div class="topbar-center">
|
||||
<span class="status-bar-text" id="statusText">等待大屏连接...</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn-control" data-control="prev" title="上一个">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M11 3L6 8L11 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button class="btn-control play-btn" data-control="play" title="播放">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor"><polygon points="5,3 15,9 5,15"/></svg>
|
||||
</button>
|
||||
<button class="btn-control play-btn" data-control="pause" title="暂停">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor"><rect x="4" y="3" width="3" height="12" rx="1"/><rect x="11" y="3" width="3" height="12" rx="1"/></svg>
|
||||
</button>
|
||||
<button class="btn-control" data-control="next" title="下一个">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M5 3L10 8L5 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<span class="topbar-divider"></span>
|
||||
{% if from_screen %}<a class="btn-return" href="/screen">返回展播</a>{% endif %}
|
||||
<a class="btn-return" href="/">数据大屏</a>
|
||||
<a class="btn-logout" href="/admin/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- 连接状态 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8 15.5a6 6 0 0 1 8 0"/><path d="M12 19h.01"/></svg></div>
|
||||
<h3>连接状态</h3>
|
||||
<!-- 顶栏 -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="brand-dot"></div>
|
||||
<h1>昆明市大学生创业园</h1>
|
||||
</div>
|
||||
<div class="conn-status">
|
||||
<div class="conn-row" id="connApi">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">后端服务</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connMqtt">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">MQTT Broker</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connScreens">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">在线大屏</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connLastCmd">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">最近指令</span>
|
||||
<span class="conn-value">—</span>
|
||||
</div>
|
||||
<div class="topbar-center">
|
||||
<span class="status-bar-text" id="statusText">等待大屏连接...</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button class="btn-control" data-control="prev" title="上一个">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11 3L6 8L11 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-control play-btn" data-control="play" title="播放">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<polygon points="5,3 15,9 5,15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-control play-btn" data-control="pause" title="暂停">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
|
||||
<rect x="4" y="3" width="3" height="12" rx="1" />
|
||||
<rect x="11" y="3" width="3" height="12" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-control" data-control="next" title="下一个">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M5 3L10 8L5 13" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="topbar-divider"></span>
|
||||
{% if from_screen %}<a class="btn-return" href="/screen">返回展播</a>{% endif %}
|
||||
<a class="btn-return" href="/">数据大屏</a>
|
||||
<a class="btn-logout" href="/admin/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页面切换 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
|
||||
<h3>目标屏幕(按 MQTT 客户端精准控制)</h3>
|
||||
</div>
|
||||
<div class="page-switch-grid" style="align-items:center;gap:8px;">
|
||||
<span id="screenCount" class="mqtt-hint" style="white-space:nowrap;">在线屏幕:加载中…</span>
|
||||
<select id="screenTarget" style="flex:1;min-width:0;padding:8px 10px;border:1px solid rgba(0,189,125,.2);border-radius:8px;background:#0e1a2b;color:#e8f0fa;font-size:12px;">
|
||||
<option value="">全部屏幕</option>
|
||||
<!-- 选择屏幕(独立模块) -->
|
||||
<div class="control-stack">
|
||||
<div class="remote-target">
|
||||
<span id="screenCount" class="remote-count">在线 0 台</span>
|
||||
<select id="screenTarget">
|
||||
<option value="">全部控制</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mqtt-hint">选择某台屏幕后,下方命令只发给它(按 client_id);「全部」则所有屏幕同步。</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg></div>
|
||||
<h3>页面切换(控制大屏展示)</h3>
|
||||
</div>
|
||||
<div class="page-switch-grid">
|
||||
<button class="page-switch-btn" data-nav="/">数据大屏</button>
|
||||
<button class="page-switch-btn" data-nav="/twin">数字孪生</button>
|
||||
<button class="page-switch-btn" data-nav="/ai">AI 助手</button>
|
||||
<button class="page-switch-btn" data-nav="/screen">媒体轮播</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 大屏 MQTT 控制(全部前端命令) -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M2 6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2z"/><path d="M8 10h8M8 14h4"/></svg></div>
|
||||
<h3>大屏控制(MQTT 指令)</h3>
|
||||
</div>
|
||||
|
||||
<!-- 页面导航 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">页面导航</div>
|
||||
<div class="mqtt-row">
|
||||
<!-- 页面切换(独立模块) -->
|
||||
<div class="card control-card">
|
||||
<div class="page-nav-prev">
|
||||
<button class="remote-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":-1}}' title="向左切页"
|
||||
aria-label="向左">‹</button>
|
||||
<button class="remote-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":1}}' title="向右切页"
|
||||
aria-label="向右">›</button>
|
||||
</div>
|
||||
<div class="mqtt-row page-nav-links">
|
||||
<button class="page-switch-btn" data-nav="/">数据大屏</button>
|
||||
<button class="page-switch-btn" data-nav="/twin">数字孪生</button>
|
||||
<button class="page-switch-btn" data-nav="/ai">AI 助手</button>
|
||||
<button class="page-switch-btn" data-nav="/screen">媒体轮播</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"navigate","params":{"page":"/voice"}}'>语音对话</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"navigate","params":{"page":"/wall"}}'>企业展示墙</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":-1}}'>← 向左</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"navigate_rel","params":{"delta":1}}'>向右 →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- 连接状态(弱化:沉到底部、紧凑展示) -->
|
||||
<div class="card conn-card" style="order:99">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M3 12h4l3 8 4-16 3 8h4" />
|
||||
</svg></div>
|
||||
<h3>连接状态</h3>
|
||||
</div>
|
||||
<div class="conn-status">
|
||||
<div class="conn-row" id="connApi">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">后端服务</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connMqtt">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">MQTT Broker</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connScreens">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">在线大屏</span>
|
||||
<span class="conn-value">检测中...</span>
|
||||
</div>
|
||||
<div class="conn-row" id="connLastCmd">
|
||||
<span class="conn-dot"></span>
|
||||
<span class="conn-label">最近指令</span>
|
||||
<span class="conn-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业展示墙 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">企业展示墙(/wall)</div>
|
||||
<!-- 媒体轮播(独立模块) -->
|
||||
<div class="card" data-page="/screen">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M6 4v16l13-8z" />
|
||||
</svg></div>
|
||||
<h3>媒体轮播</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"prev","params":{}}'>上一段</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"play","params":{}}'>播放</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"pause","params":{}}'>暂停</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"next","params":{}}'>下一段</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音对话(独立模块) -->
|
||||
<div class="card" data-page="/voice">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M9 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z" />
|
||||
<path d="M5 10a7 7 0 0 0 14 0" />
|
||||
<path d="M12 19v3" />
|
||||
</svg></div>
|
||||
<h3>语音对话</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_start","params":{}}'>启动对话</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_stop","params":{}}'>关闭对话</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_refresh","params":{}}'>刷新页面</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 助手(独立模块) -->
|
||||
<div class="card" data-page="/ai">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M12 5V3" />
|
||||
<path d="M18 4a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
|
||||
<path d="M9 7h6" />
|
||||
<path d="M10 11h.01M14 11h.01" />
|
||||
<path d="M12 13v3" />
|
||||
<path d="M7 21h10" />
|
||||
</svg></div>
|
||||
<h3>AI 助手</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<input class="mqtt-input" id="mqttAiInput" type="text" placeholder="输入问题,回车发送">
|
||||
<button class="page-switch-btn" id="mqttAiSend">发送问题</button>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":0}}'>预设①入驻</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":1}}'>预设②政策</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":2}}'>预设③企业</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数字孪生(独立模块) -->
|
||||
<div class="card" data-page="/twin">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M4 8l8-5 8 5-8 5z" />
|
||||
<path d="M4 12l8 5 8-5" />
|
||||
<path d="M4 16l8 5 8-5" />
|
||||
</svg></div>
|
||||
<h3>数字孪生</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"next"}}'>下一个企业</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"prev"}}'>上一个企业</button>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"孵化加速区"}}'>孵化加速区</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"创业苗圃区"}}'>创业苗圃区</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"国际创客区"}}'>国际创客区</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业展示墙(独立模块) -->
|
||||
<div class="card" data-page="/wall">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<rect x="3" y="21" width="18" height="3" />
|
||||
<path d="M5 21V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16" />
|
||||
<path d="M9 21v-4h6v4" />
|
||||
</svg></div>
|
||||
<h3>企业展示墙</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"wall_pause","params":{}}'>暂停滚动</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"wall_resume","params":{}}'>继续滚动</button>
|
||||
@@ -164,186 +252,191 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视觉识别 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">人物识别(全局)</div>
|
||||
<!-- 人物识别(独立模块) -->
|
||||
<!-- <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M2 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg></div>
|
||||
<h3>人物识别</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"vision_set","params":{"enabled":true}}'>开启识别</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"vision_set","params":{"enabled":false}}'>关闭识别</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- AI 助手页 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">AI 助手页</div>
|
||||
<div class="mqtt-row">
|
||||
<input class="mqtt-input" id="mqttAiInput" type="text" placeholder="输入问题,回车发送到 AI 页">
|
||||
<button class="page-switch-btn" id="mqttAiSend">发送问题</button>
|
||||
<!-- 双屏(独立模块) -->
|
||||
<!-- <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg></div>
|
||||
<h3>双屏控制</h3>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":0}}'>预设①入驻</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":1}}'>预设②政策</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_preset","params":{"index":2}}'>预设③企业</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"next"}}'>下一个企业</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_company","params":{"action":"prev"}}'>上一个企业</button>
|
||||
</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"加速区"}}'>加速区</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"国际区"}}'>国际区</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"ai_zone","params":{"zone":"成长区"}}'>成长区</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音对话页 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">语音对话页</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_start","params":{}}'>启动对话</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_stop","params":{}}'>关闭对话</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"voice_refresh","params":{}}'>刷新页面</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 双屏 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">双屏控制</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":true}}'>启动双屏</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"dual_screen","params":{"on":false}}'>关闭双屏</button>
|
||||
</div>
|
||||
<div class="mqtt-hint">在电脑另一块屏幕全屏展示(需展播机连接显示器)</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 媒体控制 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">媒体轮播</div>
|
||||
<div class="mqtt-row">
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"prev","params":{}}'>上一段</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"play","params":{}}'>播放</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"pause","params":{}}'>暂停</button>
|
||||
<button class="page-switch-btn" data-mqtt='{"action":"next","params":{}}'>下一段</button>
|
||||
<!-- 全局通知(独立模块) -->
|
||||
<!-- <div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6z" />
|
||||
<path d="M9 17v1a3 3 0 0 0 6 0v-1" />
|
||||
</svg></div>
|
||||
<h3>全局通知</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="mqtt-group">
|
||||
<div class="mqtt-group-title">全局通知</div>
|
||||
<div class="mqtt-row">
|
||||
<input class="mqtt-input" id="mqttAlertText" type="text" placeholder="通知内容,回车广播">
|
||||
<button class="page-switch-btn" id="mqttAlertSend">广播通知</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 播放设置 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
|
||||
<h3>播放设置</h3>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<div class="form-group">
|
||||
<label>播放模式</label>
|
||||
<select id="playMode">
|
||||
<option value="sequential" {% if play_mode == 'sequential' %}selected{% endif %}>顺序播放</option>
|
||||
<option value="random" {% if play_mode == 'random' %}selected{% endif %}>随机播放</option>
|
||||
</select>
|
||||
<!-- 播放设置 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<path d="M19 19l2 2" />
|
||||
</svg></div>
|
||||
<h3>播放设置</h3>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>图片时长(秒)</label>
|
||||
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量(媒体)</label>
|
||||
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
|
||||
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量(音效)</label>
|
||||
<input type="range" id="sfxVolumeRange" min="0" max="100" value="{{ sfx_volume }}">
|
||||
<div class="volume-label" id="sfxVolumeLabel">{{ sfx_volume }}%</div>
|
||||
<div class="form-inline">
|
||||
<div class="form-group">
|
||||
<label>播放模式</label>
|
||||
<select id="playMode">
|
||||
<option value="sequential" {% if play_mode=='sequential' %}selected{% endif %}>顺序播放</option>
|
||||
<option value="random" {% if play_mode=='random' %}selected{% endif %}>随机播放</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>图片时长(秒)</label>
|
||||
<input type="number" id="imageDuration" value="{{ image_duration }}" min="1" max="300">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量(媒体)</label>
|
||||
<input type="range" id="volumeRange" min="0" max="100" value="{{ volume }}">
|
||||
<div class="volume-label" id="volumeLabel">{{ volume }}%</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>音量(音效)</label>
|
||||
<input type="range" id="sfxVolumeRange" min="0" max="100" value="{{ sfx_volume }}">
|
||||
<div class="volume-label" id="sfxVolumeLabel">{{ sfx_volume }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 语音 AI 测试(admin → 前端主动问候) -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/></svg></div>
|
||||
<h3>语音 AI 测试</h3>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="flex:2;">
|
||||
<label>问候语(前端将主动播报,测试语音 AI)</label>
|
||||
<input type="text" id="voiceGreetText" value="您好呀,我是园区智能语音助手,有什么可以帮您?" style="width:100%;">
|
||||
<!-- 语音 AI 测试(admin → 前端主动问候) -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M9 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z" />
|
||||
<path d="M5 10a7 7 0 0 0 14 0" />
|
||||
<path d="M12 19v3" />
|
||||
</svg></div>
|
||||
<h3>语音 AI 测试</h3>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button class="btn-primary" id="btnVoiceGreet" style="width:100%;">播报问候</button>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="flex:2;">
|
||||
<label>问候语(前端将主动播报,测试语音 AI)</label>
|
||||
<input type="text" id="voiceGreetText" value="您好呀,我是园区智能语音助手,有什么可以帮您?" style="width:100%;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<button class="btn-primary" id="btnVoiceGreet" style="width:100%;">播报问候</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint" id="voiceGreetHint">点击后展播端将自动进入语音对话页并主动播报上述问候(LLM 生成 + TTS),用于验证语音链路。</div>
|
||||
</div>
|
||||
<div class="hint" id="voiceGreetHint">点击后展播端将自动进入语音对话页并主动播报上述问候(LLM 生成 + TTS),用于验证语音链路。</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示控制 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div>
|
||||
<h3>显示控制</h3>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<div class="form-group">
|
||||
<label>显示模式</label>
|
||||
<button class="btn-outline" id="btnFullscreen" style="width:100%;">切换大屏模式</button>
|
||||
<!-- 显示控制 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg></div>
|
||||
<h3>显示控制</h3>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>窗口操作</label>
|
||||
<button class="btn-outline" data-control="minimize" style="width:100%;">最小化窗口</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开机自启动</label>
|
||||
<button class="btn-outline" id="btnAutostart" style="width:100%;">已关闭</button>
|
||||
<div class="form-inline">
|
||||
<div class="form-group">
|
||||
<label>显示模式</label>
|
||||
<button class="btn-outline" id="btnFullscreen" style="width:100%;">切换大屏模式</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>窗口操作</label>
|
||||
<button class="btn-outline" data-control="minimize" style="width:100%;">最小化窗口</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>开机自启动</label>
|
||||
<button class="btn-outline" id="btnAutostart" style="width:100%;">已关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加媒体 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M12 16V4m0 0L7 9m5-5l5 5"/><path d="M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg></div>
|
||||
<h3>添加媒体</h3>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>本地上传</label>
|
||||
<div class="form-row">
|
||||
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple>
|
||||
<button class="btn-accent" id="btnUpload">上传</button>
|
||||
<!-- 添加媒体 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M12 16V4m0 0L7 9m5-5l5 5" />
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||
</svg></div>
|
||||
<h3>添加媒体</h3>
|
||||
</div>
|
||||
<div class="upload-progress" id="uploadProgress" style="display:none;">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
|
||||
<span class="progress-text" id="progressText"></span>
|
||||
<div class="form-group">
|
||||
<label>本地上传</label>
|
||||
<div class="form-row">
|
||||
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.jpg,.jpeg,.png" multiple>
|
||||
<button class="btn-accent" id="btnUpload">上传</button>
|
||||
</div>
|
||||
<div class="upload-progress" id="uploadProgress" style="display:none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill" style="width:0%"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="progressText"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>远程 URL / 播放链接</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4">
|
||||
<button class="btn-accent" id="btnAddUrl">添加到媒体库</button>
|
||||
<button class="btn-accent" id="btnAddUrlPlay">加入播放列表</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>远程 URL / 播放链接</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="urlInput" placeholder="https://example.com/media.mp4">
|
||||
<button class="btn-accent" id="btnAddUrl">添加到媒体库</button>
|
||||
<button class="btn-accent" id="btnAddUrlPlay">加入播放列表</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 媒体库 -->
|
||||
<div class="card full">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 20V10l6-5 6 5v10"/><path d="M10 20v-5h4v5"/></svg></div>
|
||||
<h3>媒体库({{ files|length }})</h3>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% if files %}
|
||||
<!-- 媒体库 -->
|
||||
<div class="card full">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</svg></div>
|
||||
<h3>媒体库({{ files|length }})</h3>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% if files %}
|
||||
{% for item in files %}
|
||||
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
|
||||
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}"
|
||||
data-source="{{ item.source }}" data-name="{{ item.name }}">
|
||||
{% if item.type == 'image' %}
|
||||
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
|
||||
{% else %}
|
||||
@@ -355,8 +448,10 @@
|
||||
<div class="body">
|
||||
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
|
||||
<div class="meta-row">
|
||||
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
|
||||
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
|
||||
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if
|
||||
item.source == 'url' else '本地' }}</span>
|
||||
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if
|
||||
item.type == 'video' else '图片' }}</span>
|
||||
</div>
|
||||
<div class="media-url" data-url="{{ item.url }}" title="FastAPI 输出的资源 URL">
|
||||
<span>{{ item.url }}</span>
|
||||
@@ -370,25 +465,34 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#9ca3af" stroke-width="1.4"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M3 17l5-4 4 3 4-4 5 5"/></svg></div>
|
||||
<div class="empty-icon"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#9ca3af"
|
||||
stroke-width="1.4">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<circle cx="9" cy="10" r="2" />
|
||||
<path d="M3 17l5-4 4 3 4-4 5 5" />
|
||||
</svg></div>
|
||||
暂无媒体文件<br>请上传或通过 URL 添加
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放列表 -->
|
||||
<div class="card full">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#1D5DCE" stroke-width="1.6"><path d="M4 6h16M4 12h16M4 18h10"/></svg></div>
|
||||
<h3>播放列表({{ playlist|length }})</h3>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% if playlist %}
|
||||
<!-- 播放列表 -->
|
||||
<div class="card full">
|
||||
<div class="card-header">
|
||||
<div class="card-icon"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5">
|
||||
<path d="M4 6h16M4 12h16M4 18h10" />
|
||||
</svg></div>
|
||||
<h3>播放列表({{ playlist|length }})</h3>
|
||||
</div>
|
||||
<div class="media-grid">
|
||||
{% if playlist %}
|
||||
{% for item in playlist %}
|
||||
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}" data-source="{{ item.source }}" data-name="{{ item.name }}">
|
||||
<div class="media-card" data-path="{{ item.relative_path }}" data-type="{{ item.type }}"
|
||||
data-source="{{ item.source }}" data-name="{{ item.name }}">
|
||||
{% if item.type == 'image' %}
|
||||
<img src="{{ item.url }}" loading="lazy" class="thumb" alt="{{ item.name }}" data-preview>
|
||||
{% else %}
|
||||
@@ -400,8 +504,10 @@
|
||||
<div class="body">
|
||||
<span class="name" title="{{ item.name }}">{{ item.name }}</span>
|
||||
<div class="meta-row">
|
||||
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if item.source == 'url' else '本地' }}</span>
|
||||
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if item.type == 'video' else '图片' }}</span>
|
||||
<span class="badge {{ 'badge-url' if item.source == 'url' else 'badge-local' }}">{{ '远程' if
|
||||
item.source == 'url' else '本地' }}</span>
|
||||
<span class="badge {{ 'badge-video' if item.type == 'video' else 'badge-image' }}">{{ '视频' if
|
||||
item.type == 'video' else '图片' }}</span>
|
||||
</div>
|
||||
<div class="media-url" data-url="{{ item.url }}" title="FastAPI 输出的资源 URL">
|
||||
<span>{{ item.url }}</span>
|
||||
@@ -414,47 +520,48 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">▶</div>
|
||||
播放列表为空<br>从上方媒体库添加内容
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<div class="modal-overlay" id="previewModal" style="display:none;">
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="previewTitle"></span>
|
||||
<button class="modal-close" id="previewClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewBody"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="modal-info" id="previewMeta"></span>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-accent btn-sm" id="previewPlay">立即播放</button>
|
||||
<button class="btn-accent btn-sm" id="previewAdd">加入播放</button>
|
||||
<button class="btn-danger btn-sm" id="previewDelete">删除</button>
|
||||
<!-- 预览弹窗 -->
|
||||
<div class="modal-overlay" id="previewModal" style="display:none;">
|
||||
<div class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="previewTitle"></span>
|
||||
<button class="modal-close" id="previewClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewBody"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="modal-info" id="previewMeta"></span>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-accent btn-sm" id="previewPlay">立即播放</button>
|
||||
<button class="btn-accent btn-sm" id="previewAdd">加入播放</button>
|
||||
<button class="btn-danger btn-sm" id="previewDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__DPM_INIT = {
|
||||
fullscreen: {{ 'true' if fullscreen else 'false' }},
|
||||
autostart: {{ 'true' if autostart else 'false' }}
|
||||
};
|
||||
</script>
|
||||
<script src="/static/admin.js"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
window.__DPM_INIT = {
|
||||
fullscreen: {{ 'true' if fullscreen else 'false' }},
|
||||
autostart: {{ 'true' if autostart else 'false' }},
|
||||
};
|
||||
</script>
|
||||
<script src="/static/admin.js"></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user