Files
DPM/backend/static/admin.js
T
Pine c55835fb7d fix: 管理后台连接状态 + 修复按钮无响应
连接状态(正式):
- /api/display/state 扩展:screens_online(大屏心跳统计,opc/display/heartbeat)+ last_command(最近指令与发布结果)
- 前端大屏每 10s 上报心跳(稳定 client_id),后端 30s TTL 统计在线大屏数
- 管理台新增「连接状态」卡片:后端/MQTT/在线大屏/最近指令,3s 轮询刷新

按钮无响应根治:
- admin.js 改为 document 事件委托(data-nav/data-control/data-action/复制/预览),不再依赖逐个绑定
- /static 与 /admin 响应加 no-cache 头,杜绝旧 JS/CSS 缓存导致的按钮失效
- SSE 不再自动重载页面(避免与操作回调双重重载清掉反馈);立即播放改为 toast 不重载
2026-08-17 21:54:28 +08:00

310 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =========================================================
Admin 管理后台前端逻辑(Jinja 页面配套)
事件采用 document 委托绑定,保证按钮点击稳定响应;
连接状态每 3s 轮询 /api/display/state 刷新
========================================================= */
(function () {
'use strict';
var init = window.__DPM_INIT || { fullscreen: true, autostart: false };
var fullscreen = !!init.fullscreen;
var autostart = !!init.autostart;
/* ---------- Toast ---------- */
var toastContainer = document.getElementById('toastContainer');
function toast(msg, type) {
if (!toastContainer) return;
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
toastContainer.appendChild(el);
setTimeout(function () { el.remove(); }, 2800);
}
/* ---------- 通用请求 ---------- */
function post(url, body, cb) {
fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d && d.ok === false) { toast(d.error || '操作失败', 'error'); return; }
if (cb) cb(d);
})
.catch(function () { toast('网络错误', 'error'); });
}
function reload(msg, delay) {
if (msg) toast(msg);
setTimeout(function () { location.reload(); }, delay || 600);
}
/* ---------- 播放状态(SSE,仅更新状态文字,不自动重载页面) ---------- */
var statusText = document.getElementById('statusText');
try {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
var msg; try { msg = JSON.parse(e.data); } catch (err) { return; }
if (msg.action === 'state_update' && msg.state && statusText) {
var s = msg.state;
if (!s.status || s.status === 'idle' || !s.name) statusText.textContent = '等待大屏连接...';
else if (s.status === 'playing') statusText.textContent = '正在播放 — ' + s.name;
else if (s.status === 'paused') statusText.textContent = '已暂停 — ' + s.name;
}
};
} catch (err) { /* ignore */ }
/* =========================================================
连接状态轮询(/api/display/state
========================================================= */
var connEls = {
api: document.getElementById('connApi'),
mqtt: document.getElementById('connMqtt'),
screens: document.getElementById('connScreens'),
lastCmd: document.getElementById('connLastCmd'),
};
function setConn(id, ok, value) {
var el = connEls[id];
if (!el) return;
var dot = el.querySelector('.conn-dot');
var val = el.querySelector('.conn-value');
if (dot) dot.className = 'conn-dot ' + (ok ? 'on' : 'off');
if (val) val.textContent = value;
}
function pollStatus() {
fetch('/api/display/state', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
setConn('api', true, '在线');
setConn('mqtt', !!d.mqtt_connected, d.mqtt_connected ? '已连接(' + (d.mqtt_host || '') + '' : '未连接');
setConn('screens', (d.screens_online || 0) > 0, (d.screens_online || 0) + ' 台');
var lc = d.last_command;
if (lc) {
var names = { navigate: '页面切换', play: '播放', pause: '暂停', next: '下一项', prev: '上一项',
'play_target': '指定播放', alert: '通知', show_card: '卡片', set_mode: '播放模式' };
var when = new Date(lc.ts).toLocaleTimeString('zh-CN', { hour12: false });
setConn('lastCmd', !!lc.published,
(names[lc.action] || lc.action) + ' · ' + (lc.published ? '已广播' : '未广播') + ' · ' + when);
}
})
.catch(function () {
setConn('api', false, '离线');
setConn('mqtt', false, '未知');
setConn('screens', false, '未知');
});
}
if (connEls.api) { pollStatus(); setInterval(pollStatus, 3000); }
/* =========================================================
事件委托:页面切换 / 播放控制 / 媒体操作
========================================================= */
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-nav], [data-control], [data-action], .copy-btn, [data-preview]') : null;
if (!btn) return;
e.stopPropagation();
// 预览
if (btn.hasAttribute('data-preview')) { openPreview(btn.closest('.media-card')); return; }
// 复制 URL
if (btn.classList.contains('copy-btn')) {
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(); }
return;
}
// 页面切换
if (btn.hasAttribute('data-nav')) {
var page = btn.getAttribute('data-nav');
post('/api/display/command', { action: 'navigate', params: { page: page } }, function () {
toast('已切换大屏至「' + btn.textContent.trim() + '」');
});
return;
}
// 播放控制
if (btn.hasAttribute('data-control')) {
var action = btn.getAttribute('data-control');
if (action === 'minimize') post('/api/display/command', { action: 'minimize', params: {} });
else post('/api/control', { action: action });
return;
}
// 媒体操作
if (btn.hasAttribute('data-action')) {
var card = btn.closest('.media-card');
var path = card.getAttribute('data-path');
var act = btn.getAttribute('data-action');
if (act === 'play-now') {
post('/api/playlist/play', { path: path }, function () { toast('已发送播放指令,大屏即将播放'); });
} else if (act === 'add-playlist') {
post('/api/playlist/add', { path: path }, function () { reload('已加入播放列表'); });
} else if (act === 'remove-playlist') {
post('/api/playlist/remove', { path: path }, function () { reload('已移出播放列表'); });
} else if (act === 'delete') {
if (!window.confirm('确定删除该文件?')) return;
post('/api/delete', { path: path }, function () { reload('已删除'); });
}
}
});
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);
}
/* =========================================================
播放设置(change/input 事件)
========================================================= */
var playMode = document.getElementById('playMode');
if (playMode) playMode.addEventListener('change', function () {
post('/api/settings', { play_mode: playMode.value }, function () { toast('播放模式已更新'); });
});
var imageDuration = document.getElementById('imageDuration');
if (imageDuration) imageDuration.addEventListener('change', function () {
var v = Number(imageDuration.value);
if (v < 1 || v > 300) { toast('时长需在 1-300 秒', 'error'); imageDuration.value = 5; return; }
post('/api/settings', { image_duration: v }, function () { toast('图片时长已更新'); });
});
var volumeRange = document.getElementById('volumeRange');
var volumeLabel = document.getElementById('volumeLabel');
var volTimer = null;
if (volumeRange && volumeLabel) {
volumeRange.addEventListener('input', function () {
volumeLabel.textContent = volumeRange.value + '%';
if (volTimer) clearTimeout(volTimer);
volTimer = setTimeout(function () {
post('/api/settings', { volume: Number(volumeRange.value) }, function () { toast('音量已更新'); });
}, 300);
});
}
/* ---------- 显示控制 ---------- */
var btnFullscreen = document.getElementById('btnFullscreen');
var btnAutostart = document.getElementById('btnAutostart');
function renderDisplay() {
if (btnFullscreen) btnFullscreen.textContent = fullscreen ? '切换窗口模式' : '切换大屏模式';
if (btnAutostart) { btnAutostart.textContent = autostart ? '已开启' : '已关闭'; btnAutostart.classList.toggle('active', autostart); }
}
if (btnFullscreen) btnFullscreen.addEventListener('click', function () {
fullscreen = !fullscreen; renderDisplay();
post('/api/settings', { fullscreen: fullscreen });
});
if (btnAutostart) btnAutostart.addEventListener('click', function () {
autostart = !autostart; renderDisplay();
post('/api/settings', { autostart: autostart });
});
renderDisplay();
/* ---------- 上传(带进度) ---------- */
var btnUpload = document.getElementById('btnUpload');
if (btnUpload) btnUpload.addEventListener('click', function () {
var inp = document.getElementById('fileInput');
var fileList = Array.from(inp.files || []);
if (fileList.length === 0) { toast('请选择文件', 'error'); return; }
var prog = document.getElementById('uploadProgress');
var fill = document.getElementById('progressFill');
var text = document.getElementById('progressText');
prog.style.display = 'block';
var done = 0;
fileList.forEach(function (file) {
var fd = new FormData();
fd.append('file', file);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
var overall = Math.round(((done + e.loaded / e.total) / fileList.length) * 100);
fill.style.width = overall + '%';
text.textContent = file.name + ' (' + (done + 1) + '/' + fileList.length + ') — ' + pct + '%';
}
};
xhr.onload = function () {
done += 1;
if (done === fileList.length) reload('上传完成');
};
xhr.onerror = function () { toast(file.name + ' 上传失败', 'error'); done += 1; };
xhr.send(fd);
});
});
/* ---------- 远程 URL ---------- */
var btnAddUrl = document.getElementById('btnAddUrl');
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) {
if (play) {
post('/api/playlist/play', { path: url }, function () { toast('已加入并发送播放指令'); });
} else {
reload(d && d.duplicate ? 'URL 已存在于媒体库' : '已添加到媒体库');
}
});
}
if (btnAddUrl) btnAddUrl.addEventListener('click', function () { addUrlThen(false); });
if (btnAddUrlPlay) btnAddUrlPlay.addEventListener('click', function () { addUrlThen(true); });
/* =========================================================
预览弹窗
========================================================= */
var modal = document.getElementById('previewModal');
var modalBody = document.getElementById('previewBody');
var current = null;
function openPreview(card) {
if (!card || !modal) return;
var path = card.getAttribute('data-path');
var type = card.getAttribute('data-type');
var source = card.getAttribute('data-source');
var name = card.getAttribute('data-name');
var url = source === 'url' ? path : '/file/' + encodeURI(path);
current = { path: path, url: url, type: type, name: name };
document.getElementById('previewTitle').textContent = name;
modalBody.innerHTML = type === 'image'
? '<img src="' + url + '" alt="' + name + '">'
: '<video src="' + url + '" controls autoplay playsinline></video>';
document.getElementById('previewMeta').innerHTML =
'<span class="badge ' + (source === 'url' ? 'badge-url' : 'badge-local') + '">' + (source === 'url' ? '远程' : '本地') + '</span>' +
'<span class="badge ' + (type === 'video' ? 'badge-video' : 'badge-image') + '">' + (type === 'video' ? '视频' : '图片') + '</span>';
modal.style.display = 'flex';
}
function closePreview() {
var v = modalBody.querySelector('video');
if (v) { v.pause(); v.removeAttribute('src'); v.load(); }
modalBody.innerHTML = '';
modal.style.display = 'none';
current = null;
}
document.getElementById('previewClose').addEventListener('click', closePreview);
if (modal) modal.addEventListener('click', function (e) {
if (e.target === modal) closePreview();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closePreview(); });
document.getElementById('previewAdd').addEventListener('click', function () {
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;
post('/api/delete', { path: current.path }, function () { closePreview(); reload('已删除'); });
});
})();