338 行
15 KiB
HTML
338 行
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>屏幕播放控制系统 - 控制台</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
|
background: #f5f6fa; color: #2f3640; line-height: 1.5;
|
|
}
|
|
header {
|
|
background: #273c75; color: #fff;
|
|
padding: 16px 24px; display: flex; align-items: center; gap: 16px;
|
|
}
|
|
header h1 { font-size: 20px; }
|
|
.layout { display: flex; height: calc(100vh - 64px); }
|
|
.sidebar {
|
|
width: 240px; background: #fff; border-right: 1px solid #e1e2e6;
|
|
padding: 16px; overflow-y: auto;
|
|
}
|
|
.main { flex: 1; padding: 24px; overflow-y: auto; }
|
|
.device-item {
|
|
padding: 10px 12px; border-radius: 8px; cursor: pointer;
|
|
margin-bottom: 8px; border: 1px solid transparent;
|
|
}
|
|
.device-item:hover { background: #f0f1f5; }
|
|
.device-item.active { background: #e8f0fe; border-color: #273c75; }
|
|
.btn {
|
|
border: none; border-radius: 6px; padding: 8px 14px;
|
|
cursor: pointer; font-size: 14px; background: #273c75; color: #fff;
|
|
}
|
|
.btn:hover { opacity: 0.9; }
|
|
.btn.secondary { background: #7f8fa6; }
|
|
.btn.danger { background: #c23616; }
|
|
.btn.small { padding: 4px 10px; font-size: 12px; }
|
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
.card { background: #fff; border-radius: 10px; padding: 18px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; font-size: 14px; }
|
|
input, select { padding: 6px 8px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
|
|
.empty { color: #999; padding: 24px; text-align: center; }
|
|
.player-link { font-size: 12px; color: #273c75; text-decoration: none; }
|
|
#status { margin-top: 12px; font-size: 14px; color: #273c75; }
|
|
.topbar { display:flex; justify-content: space-between; align-items: center; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>屏幕播放控制系统</h1>
|
|
<span style="font-size:13px;opacity:.8;">展厅多屏定时播放控制台</span>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<!-- 左侧设备列表 -->
|
|
<div class="sidebar">
|
|
<div class="topbar" style="margin-bottom:12px;">
|
|
<strong>设备</strong>
|
|
<button class="btn small" onclick="addDevice()">+ 新增</button>
|
|
</div>
|
|
<div id="device-list"></div>
|
|
</div>
|
|
|
|
<!-- 右侧主区域 -->
|
|
<div class="main">
|
|
<div id="detail" class="empty">请选择左侧设备,或点击「新增」创建设备。</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status"></div>
|
|
|
|
<script>
|
|
let config = { devices: [] };
|
|
let activeId = null;
|
|
|
|
// ---------- 数据加载 ----------
|
|
async function fetchConfig() {
|
|
const resp = await fetch("/get_config");
|
|
config = await resp.json();
|
|
if (!config.devices) config.devices = [];
|
|
renderDeviceList();
|
|
if (activeId && !findDevice(activeId)) activeId = null;
|
|
if (!activeId && config.devices.length) activeId = config.devices[0].id;
|
|
renderDetail();
|
|
}
|
|
|
|
function findDevice(id) {
|
|
return config.devices.find(d => d.id === id);
|
|
}
|
|
|
|
async function saveConfig() {
|
|
const resp = await fetch("/save_config", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(config),
|
|
});
|
|
const r = await resp.json();
|
|
setStatus(r.status === "success" ? "配置已保存" : ("保存失败:" + r.message));
|
|
if (r.status === "success") fetchConfig();
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
const el = document.getElementById("status");
|
|
el.innerText = msg + " @" + new Date().toLocaleTimeString();
|
|
}
|
|
|
|
// ---------- 设备列表 ----------
|
|
function renderDeviceList() {
|
|
const box = document.getElementById("device-list");
|
|
box.innerHTML = "";
|
|
if (!config.devices.length) {
|
|
box.innerHTML = '<div class="empty">暂无设备</div>';
|
|
return;
|
|
}
|
|
config.devices.forEach(d => {
|
|
const div = document.createElement("div");
|
|
div.className = "device-item" + (d.id === activeId ? " active" : "");
|
|
div.onclick = () => { activeId = d.id; renderDeviceList(); renderDetail(); };
|
|
div.innerHTML = `<div><strong>${escapeHtml(d.name || d.id)}</strong></div>
|
|
<div style="font-size:12px;color:#888;">${d.id} · ${(d.playlist||[]).length} 条</div>`;
|
|
box.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function addDevice() {
|
|
const id = "d" + (config.devices.length + 1);
|
|
config.devices.push({ id, name: "新设备", playlist: [] });
|
|
activeId = id;
|
|
renderDeviceList();
|
|
renderDetail();
|
|
saveConfig();
|
|
}
|
|
|
|
function removeDevice() {
|
|
if (!activeId) return;
|
|
if (!confirm("确认删除该设备及其播放列表?")) return;
|
|
config.devices = config.devices.filter(d => d.id !== activeId);
|
|
activeId = config.devices.length ? config.devices[0].id : null;
|
|
renderDeviceList();
|
|
renderDetail();
|
|
saveConfig();
|
|
}
|
|
|
|
// ---------- 详情 / 播放列表 ----------
|
|
async function renderDetail() {
|
|
const box = document.getElementById("detail");
|
|
const device = findDevice(activeId);
|
|
if (!device) { box.className = "empty"; box.innerText = "请选择设备"; return; }
|
|
box.className = "";
|
|
|
|
const playerUrl = `${location.origin}/player?device_id=${device.id}`;
|
|
let html = `
|
|
<div class="card">
|
|
<div class="row" style="justify-content:space-between;margin-bottom:12px;">
|
|
<div class="row">
|
|
<label>设备名称: <input id="dev-name" value="${escapeHtml(device.name||'')}" style="width:160px;"></label>
|
|
<label>设备ID: <input id="dev-id" value="${escapeHtml(device.id)}" style="width:120px;"></label>
|
|
</div>
|
|
<div class="row">
|
|
<a class="player-link" href="/player?device_id=${device.id}" target="_blank">打开播放端 ↗</a>
|
|
<button class="btn small secondary" onclick="copyPlayerUrl('${playerUrl}')">复制播放链接</button>
|
|
<button class="btn small danger" onclick="removeDevice()">删除设备</button>
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<button class="btn small" onclick="saveDeviceMeta()">保存设备信息</button>
|
|
<span style="font-size:12px;color:#888;">播放端地址:${playerUrl}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="topbar" style="margin-bottom:12px;">
|
|
<strong>播放列表(按开始时间升序)</strong>
|
|
<button class="btn small" onclick="addItem()">+ 添加条目</button>
|
|
</div>
|
|
<div id="playlist-box"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="topbar" style="margin-bottom:12px;">
|
|
<strong>媒体库</strong>
|
|
<label class="btn small">
|
|
上传媒体
|
|
<input id="file-input" type="file" accept="image/*,video/*" style="display:none;" onchange="uploadMedia()">
|
|
</label>
|
|
</div>
|
|
<div id="file-gallery"></div>
|
|
</div>
|
|
`;
|
|
box.innerHTML = html;
|
|
renderPlaylist(device);
|
|
fetchFiles();
|
|
}
|
|
|
|
function renderPlaylist(device) {
|
|
const box = document.getElementById("playlist-box");
|
|
if (!device.playlist || !device.playlist.length) {
|
|
box.innerHTML = '<div class="empty">暂无播放条目</div>';
|
|
return;
|
|
}
|
|
let rows = device.playlist.map((it, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td>
|
|
<select onchange="updateItem(${i},'type',this.value)">
|
|
<option value="image" ${it.type==='image'?'selected':''}>图片</option>
|
|
<option value="video" ${it.type==='video'?'selected':''}>视频</option>
|
|
</select>
|
|
</td>
|
|
<td><input style="width:260px;" value="${escapeHtml(it.path||'')}" onchange="updateItem(${i},'path',this.value)"></td>
|
|
<td><input type="datetime-local" value="${toLocalInput(it.start_time)}" onchange="updateItem(${i},'start_time',this.value)"></td>
|
|
<td><input style="width:70px;" type="number" min="1" value="${it.duration||10}" onchange="updateItem(${i},'duration',this.value)" title="图片展示秒数"></td>
|
|
<td>
|
|
<button class="btn small danger" onclick="removeItem(${i})">删除</button>
|
|
</td>
|
|
</tr>`).join("");
|
|
box.innerHTML = `
|
|
<table>
|
|
<thead><tr><th>#</th><th>类型</th><th>路径</th><th>开始时间</th><th>时长(秒)</th><th>操作</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
<div class="row" style="margin-top:12px;">
|
|
<button class="btn small" onclick="saveDeviceMeta()">保存播放列表</button>
|
|
</div>`;
|
|
}
|
|
|
|
function saveDeviceMeta() {
|
|
const device = findDevice(activeId);
|
|
if (!device) return;
|
|
device.name = document.getElementById("dev-name").value;
|
|
const newId = document.getElementById("dev-id").value.trim();
|
|
if (newId && newId !== device.id) {
|
|
if (config.devices.some(d => d.id === newId)) {
|
|
alert("该设备ID已存在"); return;
|
|
}
|
|
device.id = newId; activeId = newId;
|
|
}
|
|
saveConfig();
|
|
}
|
|
|
|
function addItem() {
|
|
const device = findDevice(activeId);
|
|
if (!device) return;
|
|
device.playlist = device.playlist || [];
|
|
device.playlist.push({ type: "image", path: "/static/", start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
|
renderDetail();
|
|
}
|
|
|
|
function removeItem(i) {
|
|
const device = findDevice(activeId);
|
|
device.playlist.splice(i, 1);
|
|
renderDetail();
|
|
}
|
|
|
|
function updateItem(i, key, value) {
|
|
const device = findDevice(activeId);
|
|
if (key === "duration") value = parseInt(value, 10) || 10;
|
|
device.playlist[i][key] = value;
|
|
}
|
|
|
|
// ---------- 工具 ----------
|
|
function escapeHtml(s) {
|
|
return String(s == null ? "" : s).replace(/[&<>"']/g, c => (
|
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
|
));
|
|
}
|
|
|
|
function toLocalInput(iso) {
|
|
if (!iso) return "";
|
|
const d = new Date(iso);
|
|
if (isNaN(d)) return "";
|
|
const pad = n => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function copyPlayerUrl(url) {
|
|
navigator.clipboard.writeText(url).then(() => setStatus("播放链接已复制"));
|
|
}
|
|
|
|
// ---------- 媒体文件上传 ----------
|
|
async function uploadMedia() {
|
|
const input = document.getElementById("file-input");
|
|
if (!input.files.length) return;
|
|
const fd = new FormData();
|
|
fd.append("file", input.files[0]);
|
|
const resp = await fetch("/upload", { method: "POST", body: fd });
|
|
const r = await resp.json();
|
|
if (r.status === "success") {
|
|
setStatus("上传成功:" + r.filename);
|
|
// 若播放列表正在编辑,自动填入路径
|
|
const device = findDevice(activeId);
|
|
if (device) {
|
|
device.playlist = device.playlist || [];
|
|
device.playlist.push({ type: r.type, path: r.path, start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
|
}
|
|
renderDetail();
|
|
fetchFiles();
|
|
} else {
|
|
alert("上传失败:" + r.message);
|
|
}
|
|
input.value = "";
|
|
}
|
|
|
|
async function fetchFiles() {
|
|
const resp = await fetch("/files");
|
|
const data = await resp.json();
|
|
const box = document.getElementById("file-gallery");
|
|
if (!box) return;
|
|
if (!data.files.length) { box.innerHTML = '<div class="empty">暂无媒体文件,请上传</div>'; return; }
|
|
box.innerHTML = data.files.map(f => `
|
|
<div style="display:inline-block;margin:6px;text-align:center;">
|
|
<div style="width:120px;height:80px;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:6px;">
|
|
${f.type==='image'
|
|
? `<img src="${f.path}" style="max-width:100%;max-height:100%;">`
|
|
: `<span style="color:#fff;font-size:12px;">🎬 视频</span>`}
|
|
</div>
|
|
<div style="font-size:11px;width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f.name}</div>
|
|
<button class="btn small" onclick="useFile('${f.path}','${f.type}')">使用</button>
|
|
</div>`).join("");
|
|
}
|
|
|
|
function useFile(path, type) {
|
|
const device = findDevice(activeId);
|
|
if (!device) { alert("请先选择设备"); return; }
|
|
device.playlist = device.playlist || [];
|
|
device.playlist.push({ type, path, start_time: toLocalInput(new Date().toISOString()), duration: 10 });
|
|
renderDetail();
|
|
setStatus("已添加:" + path);
|
|
}
|
|
|
|
// 初始化
|
|
fetchConfig();
|
|
setInterval(fetchFiles, 10000);
|
|
</script>
|
|
</body>
|
|
</html>
|