refactor(park-desktop): admin 后台新 UI + vision_yolo 手势接入,清理设计文档与安装包,补充工作区规范

This commit is contained in:
Pine
2026-08-24 11:27:55 +08:00
parent 9f2b90aeca
commit 4e73a6bba8
19 changed files with 700 additions and 1239 deletions
+2
View File
@@ -1,5 +1,7 @@
# OPC 智能园区后端(FastAPI + MQTT
> 📍 工作区位置:`code/dpm/backend/`DPM 园区大屏后端,独立仓库)。权威说明以本文档为准;系统/运维文档见 `design/dpm/`,前端见 `code/dpm/src/`。
独立后端服务:**所有数据通过 REST API 提供**,**页面/媒体控制通过 MQTT 下发**,媒体资源由后端统一存储与返回。
## 架构
+4
View File
@@ -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
View File
@@ -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)
+39
View File
@@ -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)
+125
View File
@@ -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);
}
+17
View File
@@ -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
View File
@@ -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>&nbsp;</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>&nbsp;</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">&times;</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">&times;</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>