feat: 管理后台补齐完整功能

- 页面切换卡片:数据大屏/数字孪生/AI 助手/媒体轮播 → MQTT navigate 控制所有大屏
- 立即播放:新增 POST /api/playlist/play(加入播放列表 + MQTT play_target),
  MediaScreen 监听 play_target 跳转并播放指定媒体
- 远程 URL 支持两种操作:添加到媒体库 / 加入播放列表
- 本地媒体卡片显示 FastAPI 输出 URL(/file/...)+ 一键复制
- 预览弹窗增加「立即播放」;新增 page-switch/media-url/copy-btn 样式
This commit is contained in:
Pine
2026-08-17 21:38:04 +08:00
parent 00cb76c73f
commit 1a95ecbacb
6 changed files with 173 additions and 8 deletions
+10
View File
@@ -223,6 +223,16 @@ async def remove_playlist(body: PathBody):
return {"ok": True}
@router.post("/api/playlist/play")
async def play_playlist_item(body: PathBody):
"""指定媒体立即播放:加入播放列表(如未在)→ MQTT 广播 play_target → 大屏跳转播放"""
path = body.path
storage.add_to_playlist(path)
hub.publish_command("playlist_changed")
cmd = hub.publish_command("play_target", {"path": path})
return {"ok": True, "cmd_id": cmd.get("cmd_id")}
# ==================== 播放控制 / 状态 ====================
_CONTROL_ACTIONS = {"play", "pause", "next", "prev"}
+1 -1
View File
@@ -5,4 +5,4 @@ import uvicorn
from app.config import settings
if __name__ == "__main__":
uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=False)
uvicorn.run("app.main:app", host=settings.HOST, port=settings.PORT, reload=True)
+64
View File
@@ -1123,3 +1123,67 @@ button { font-family: "Poppins", "PingFang SC", sans-serif; cursor: pointer; tra
.form-inline { gap: var(--space-8); align-items: stretch; }
.form-group { margin-bottom: var(--space-8); }
}
/* =========================================================
管理后台增强:页面切换 / 立即播放 / URL 展示
========================================================= */
.page-switch-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.page-switch-btn {
padding: 12px 6px;
font-size: 13px;
font-weight: 600;
color: var(--primary, #00BD7D);
background: var(--primary-subtle, rgba(0,189,125,0.07));
border: 1px solid rgba(0, 189, 125, 0.28);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.page-switch-btn:hover {
background: rgba(0, 189, 125, 0.14);
transform: translateY(-1px);
}
.media-url {
display: flex;
align-items: center;
gap: 6px;
margin: 4px 0 6px;
padding: 3px 6px;
min-width: 0;
background: rgba(0, 189, 125, 0.05);
border: 1px solid rgba(0, 189, 125, 0.14);
border-radius: 6px;
}
.media-url span {
flex: 1;
min-width: 0;
font-size: 10px;
font-family: "SF Mono", "JetBrains Mono", Menlo, monospace;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
flex-shrink: 0;
padding: 2px 7px;
font-size: 10px;
border: none;
border-radius: 5px;
background: rgba(0, 189, 125, 0.14);
color: var(--primary, #00BD7D);
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover { background: rgba(0, 189, 125, 0.24); }
.media-card .actions {
flex-wrap: wrap;
gap: 5px;
}
@media (max-width: 900px) {
.page-switch-grid { grid-template-columns: repeat(2, 1fr); }
}
+49 -4
View File
@@ -55,6 +55,16 @@
};
} catch (err) { /* ignore */ }
/* ---------- 页面切换(MQTT 控制大屏) ---------- */
document.querySelectorAll('[data-nav]').forEach(function (btn) {
btn.addEventListener('click', function () {
var page = btn.getAttribute('data-nav');
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () {
toast('已切换大屏至「' + btn.textContent.trim() + '」');
});
});
});
/* ---------- 播放控制 ---------- */
document.querySelectorAll('[data-control]').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -150,23 +160,54 @@
/* ---------- 远程 URL ---------- */
var btnAddUrl = document.getElementById('btnAddUrl');
if (btnAddUrl) btnAddUrl.addEventListener('click', function () {
var btnAddUrlPlay = document.getElementById('btnAddUrlPlay');
function addUrlThen(play) {
var inp = document.getElementById('urlInput');
var url = (inp.value || '').trim();
if (!url) { toast('请输入 URL', 'error'); return; }
post('/api/media/add-url', { url: url }, function (d) {
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
if (play) {
post('/api/playlist/play', { path: url }, function () { reload('已加入并开始播放'); });
} else {
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
}
});
}
if (btnAddUrl) btnAddUrl.addEventListener('click', function () { addUrlThen(false); });
if (btnAddUrlPlay) btnAddUrlPlay.addEventListener('click', function () { addUrlThen(true); });
/* ---------- 复制资源 URLFastAPI 输出) ---------- */
document.querySelectorAll('.copy-btn').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var url = btn.closest('.media-url').getAttribute('data-url') || '';
var full = /^https?:\/\//.test(url) ? url : window.location.origin + url;
var done = function () { toast('链接已复制'); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(full).then(done).catch(function () { fallbackCopy(full); done(); });
} else {
fallbackCopy(full); done();
}
});
});
function fallbackCopy(text) {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (err) { /* ignore */ }
document.body.removeChild(ta);
}
/* ---------- 媒体操作(加入播放 / 移出 / 删除) ---------- */
/* ---------- 媒体操作(立即播放 / 加入播放 / 移出 / 删除) ---------- */
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var card = btn.closest('.media-card');
var path = card.getAttribute('data-path');
var action = btn.getAttribute('data-action');
if (action === 'add-playlist') {
if (action === 'play-now') {
post('/api/playlist/play', { path: path }, function () { toast('正在播放'); });
} else if (action === 'add-playlist') {
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
} else if (action === 'remove-playlist') {
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
@@ -217,6 +258,10 @@
if (!current) return;
post('/api/playlist/add', { path: current.path }, function () { closePreview(); reload('已加入播放列表'); });
});
document.getElementById('previewPlay').addEventListener('click', function () {
if (!current) return;
post('/api/playlist/play', { path: current.path }, function () { closePreview(); toast('正在播放'); });
});
document.getElementById('previewDelete').addEventListener('click', function () {
if (!current) return;
if (!window.confirm('确定删除该文件?')) return;
+28 -2
View File
@@ -74,6 +74,20 @@
</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"><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>
<!-- 播放设置 -->
<div class="card">
<div class="card-header">
@@ -140,10 +154,11 @@
</div>
</div>
<div class="form-group">
<label>远程 URL</label>
<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="btnAddUrl">添加到媒体库</button>
<button class="btn-accent" id="btnAddUrlPlay">加入播放列表</button>
</div>
</div>
</div>
@@ -172,7 +187,12 @@
<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>
<button class="copy-btn" title="复制链接">复制</button>
</div>
<div class="actions">
<button class="btn-accent btn-sm" data-action="play-now">立即播放</button>
<button class="btn-accent btn-sm" data-action="add-playlist">加入播放</button>
<button class="btn-danger btn-sm" data-action="delete">删除</button>
</div>
@@ -212,7 +232,12 @@
<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>
<button class="copy-btn" title="复制链接">复制</button>
</div>
<div class="actions">
<button class="btn-accent btn-sm" data-action="play-now">立即播放</button>
<button class="btn-danger btn-sm" data-action="remove-playlist">移出</button>
</div>
</div>
@@ -240,6 +265,7 @@
<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>
+21 -1
View File
@@ -209,6 +209,9 @@ export default function MediaScreen() {
setPaused(false);
skip(-1);
break;
case 'play_target':
if (msg.params && msg.params.path) playTarget(msg.params.path);
break;
case 'minimize_window':
getCurrentWindow().minimize().catch(() => {});
break;
@@ -292,6 +295,20 @@ export default function MediaScreen() {
}
}, []);
// 指定媒体立即播放:刷新播放列表并跳转到目标项
const playTarget = useCallback(async (path) => {
try {
const data = await api.getPlaylist();
const newList = data.files || [];
if (newList.length === 0) return;
setList(newList);
setLoaded(true);
const idx = newList.findIndex((it) => it.relative_path === path);
if (idx >= 0) setIndex(idx);
setPaused(false);
} catch { /* ignore */ }
}, []);
// MQTT 控制通道(后端 opc/display/command → window 事件)
useEffect(() => {
const handler = (e) => {
@@ -316,6 +333,9 @@ export default function MediaScreen() {
case 'set_mode':
if (msg.params && msg.params.mode) setPlayMode(msg.params.mode);
break;
case 'play_target':
if (msg.params && msg.params.path) playTarget(msg.params.path);
break;
case 'minimize':
getCurrentWindow().minimize().catch(() => {});
break;
@@ -331,7 +351,7 @@ export default function MediaScreen() {
};
window.addEventListener('dpm:media-control', handler);
return () => window.removeEventListener('dpm:media-control', handler);
}, [skip, reloadPlaylist, applySettings]);
}, [skip, reloadPlaylist, applySettings, playTarget]);
const enableSound = useCallback(() => {
const video = videoRef.current;