406 lines
18 KiB
JavaScript
406 lines
18 KiB
JavaScript
/* =========================================================
|
||
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;
|
||
|
||
/* 目标屏(双屏独立控制):both | main | secondary */
|
||
var screenTarget = 'both';
|
||
|
||
/* ---------- 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('keydown', function (e) {
|
||
if (e.key !== 'Enter') return;
|
||
if (e.target && e.target.id === 'mqttAiInput') { document.getElementById('mqttAiSend').click(); }
|
||
else if (e.target && e.target.id === 'mqttAlertText') { document.getElementById('mqttAlertSend').click(); }
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
var btn = e.target.closest ? e.target.closest('[data-nav], [data-mqtt], [data-control], [data-action], .copy-btn, [data-preview], .screen-target, #mqttAiSend, #mqttAlertSend') : null;
|
||
if (!btn) return;
|
||
e.stopPropagation();
|
||
|
||
// 目标屏选择(双屏独立控制)
|
||
if (btn.classList.contains('screen-target')) {
|
||
screenTarget = btn.getAttribute('data-screen') || 'both';
|
||
var btns = document.querySelectorAll('.screen-target');
|
||
for (var i = 0; i < btns.length; i++) btns[i].classList.toggle('active', btns[i] === btn);
|
||
toast('目标屏:' + ({ both: '全部(镜像)', main: '仅主屏', secondary: '仅副屏' }[screenTarget] || screenTarget));
|
||
return;
|
||
}
|
||
|
||
// 预览
|
||
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;
|
||
}
|
||
|
||
// 大屏 MQTT 指令(data-mqtt 携带完整 {action, params} JSON)
|
||
if (btn.hasAttribute('data-mqtt')) {
|
||
var cmd = null;
|
||
try { cmd = JSON.parse(btn.getAttribute('data-mqtt')); } catch (err) { toast('指令 JSON 错误', 'error'); return; }
|
||
// 双屏独立控制:除「双屏开关」外,注入目标屏(main/secondary/both)
|
||
if (cmd.action !== 'dual_screen') cmd.screen = screenTarget;
|
||
post('/api/display/command', cmd, function (d) {
|
||
toast(d && d.ok !== false ? '指令已发送:' + (cmd.action || '') : '发送失败(MQTT 未连接?)',
|
||
d && d.ok !== false ? 'ok' : 'error');
|
||
});
|
||
return;
|
||
}
|
||
|
||
// AI 提问输入框
|
||
if (btn.id === 'mqttAiSend') {
|
||
var text = (document.getElementById('mqttAiInput').value || '').trim();
|
||
if (!text) { toast('请输入问题', 'error'); return; }
|
||
post('/api/display/command', { action: 'ai_input', params: { text: text } }, function () {
|
||
toast('已发送问题到大屏 AI 页');
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 通知输入框
|
||
if (btn.id === 'mqttAlertSend') {
|
||
var alertText = (document.getElementById('mqttAlertText').value || '').trim();
|
||
if (!alertText) { toast('请输入通知内容', 'error'); return; }
|
||
post('/api/display/command', { action: 'alert', params: { text: alertText } }, function () {
|
||
toast('通知已广播');
|
||
});
|
||
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 sfxVolumeRange = document.getElementById('sfxVolumeRange');
|
||
var sfxVolumeLabel = document.getElementById('sfxVolumeLabel');
|
||
var sfxVolTimer = null;
|
||
if (sfxVolumeRange && sfxVolumeLabel) {
|
||
sfxVolumeRange.addEventListener('input', function () {
|
||
sfxVolumeLabel.textContent = sfxVolumeRange.value + '%';
|
||
if (sfxVolTimer) clearTimeout(sfxVolTimer);
|
||
sfxVolTimer = setTimeout(function () {
|
||
post('/api/settings', { sfx_volume: Number(sfxVolumeRange.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('已删除'); });
|
||
});
|
||
})();
|
||
/* ---------- 语音 AI 测试(主动问候) ---------- */
|
||
(function () {
|
||
var greetBtn = document.getElementById('btnVoiceGreet');
|
||
var greetInput = document.getElementById('voiceGreetText');
|
||
var greetHint = document.getElementById('voiceGreetHint');
|
||
if (!greetBtn || !greetInput) return;
|
||
greetBtn.addEventListener('click', function () {
|
||
var text = (greetInput.value || '').trim();
|
||
if (!text) { toast('请输入问候语'); return; }
|
||
greetBtn.disabled = true;
|
||
greetBtn.textContent = '发送中…';
|
||
fetch('/api/display/command', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action: 'voice_start', params: { text: text } }),
|
||
})
|
||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||
.then(function (d) {
|
||
greetBtn.disabled = false;
|
||
greetBtn.textContent = '播报问候';
|
||
if (d && d.ok === false) { toast('发送失败:MQTT 未发布(' + (d.error || '未知') + ')', 'error'); return; }
|
||
if (greetHint) greetHint.textContent = '✅ 已发送:展播端将进入语音对话页并主动播报「' + text + '」,请留意展播端声音。';
|
||
toast('语音问候已发送');
|
||
})
|
||
.catch(function () {
|
||
greetBtn.disabled = false;
|
||
greetBtn.textContent = '播报问候';
|
||
toast('发送失败:后端不可达', 'error');
|
||
});
|
||
});
|
||
})();
|