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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
|
||||
/* ---------- 复制资源 URL(FastAPI 输出) ---------- */
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user