文件
wangchuanli efc80ca1e0 feat: 初始化屏幕播放控制系统
搭建基于 Flask 的多屏幕定时播放控制后端,提供设备管理控制台、播放端页面、媒体上传与配置文件持久化,并补充项目文档、示例配置及 MIT 许可证。
2026-08-24 10:51:18 +08:00

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 => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>