This commit is contained in:
TJY
2026-07-16 08:31:52 +08:00
commit 8590fafac1
34 changed files with 2611 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LED Wall Control</title>
<link rel="stylesheet" href="/static/admin/styles.css" />
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>LED 大屏投放控制台</h1>
<p id="stateText">正在连接控制服务...</p>
</div>
<div class="connection">
<span id="wsDot" class="dot"></span>
<span id="wsText">WebSocket</span>
</div>
</header>
<section class="grid">
<section class="panel scenes">
<div class="panelHead">
<h2>场景</h2>
<button id="refreshBtn">刷新</button>
</div>
<div id="sceneList" class="sceneList"></div>
</section>
<section class="panel">
<div class="panelHead">
<h2>输出与同步</h2>
</div>
<div class="linkGrid">
<a href="/output/left" target="_blank">打开左输出</a>
<a href="/output/right" target="_blank">打开右输出</a>
<a href="/docs" target="_blank">API 文档</a>
</div>
<div id="syncStatus" class="syncStatus"></div>
</section>
<section class="panel">
<div class="panelHead">
<h2>局部动作</h2>
</div>
<div class="actionGrid">
<button data-action="page.prev">上一页</button>
<button data-action="page.next">下一页</button>
<button data-action="energy.mode" data-args='{"mode":"削峰"}'>削峰模式</button>
<button data-action="energy.mode" data-args='{"mode":"保供"}'>保供模式</button>
<button data-action="security.alert" data-args='{"text":"门禁异常联动"}'>新增告警</button>
<button data-action="timeline.pause">暂停时间轴</button>
<button data-action="timeline.resume">恢复时间轴</button>
</div>
<label class="customAction">
<span>自定义结构化动作</span>
<textarea id="actionInput" rows="5" placeholder='{"action":"security.alert","args":{"text":"消防通道占用"}}'></textarea>
</label>
<button id="runActionBtn" class="primary">同步执行</button>
</section>
</section>
</main>
<script src="/static/admin/main.js"></script>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
const sceneList = document.querySelector("#sceneList");
const stateText = document.querySelector("#stateText");
const wsDot = document.querySelector("#wsDot");
const wsText = document.querySelector("#wsText");
const refreshBtn = document.querySelector("#refreshBtn");
const runActionBtn = document.querySelector("#runActionBtn");
const actionInput = document.querySelector("#actionInput");
const syncStatus = document.querySelector("#syncStatus");
let scenes = [];
let state = null;
let syncSnapshot = null;
async function loadScenes() {
const res = await fetch("/api/scenes");
const data = await res.json();
scenes = data.scenes;
state = data.state;
renderScenes();
}
async function loadSyncStatus() {
const res = await fetch("/api/sync/status");
syncSnapshot = await res.json();
renderSyncStatus();
}
function renderScenes() {
if (!state) return;
stateText.textContent = `当前场景:${state.active_scene_id},版本 ${state.version}`;
sceneList.innerHTML = "";
for (const scene of scenes) {
const item = document.createElement("article");
item.className = `scene ${scene.id === state.active_scene_id ? "active" : ""}`;
item.innerHTML = `
<div>
<h3>${scene.name}</h3>
<p>${scene.view} | ${scene.url}</p>
<p>${scene.description ?? ""}</p>
</div>
<button data-scene="${scene.id}">切换</button>
`;
item.querySelector("button").addEventListener("click", () => switchScene(scene.id));
sceneList.append(item);
}
}
function renderSyncStatus() {
if (!syncSnapshot) return;
const nodes = syncSnapshot.nodes || [];
const commands = (syncSnapshot.commands || []).slice(-4).reverse();
const rows = [];
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
for (const node of nodes) {
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`;
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
rows.push(`<div class="syncRow"><span>${node.tile_id} ${node.node_id.slice(0, 16)}</span><span>${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}</span></div>`);
}
for (const command of commands) {
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
}
syncStatus.innerHTML = rows.join("");
}
async function switchScene(sceneId) {
const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" });
if (!res.ok) {
alert(await res.text());
return;
}
const data = await res.json();
state = data.state;
const time = new Date(data.apply_at_ms).toLocaleTimeString("zh-CN", { hour12: false });
stateText.textContent = `计划切换:${state.active_scene_id},提交时间 ${time}`;
renderScenes();
loadSyncStatus();
}
async function postAction(action, args = {}, target = "wall") {
const res = await fetch("/api/actions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target, action, args }),
});
if (!res.ok) {
alert(await res.text());
return;
}
loadSyncStatus();
}
async function runCustomAction() {
const raw = actionInput.value.trim();
if (!raw) return;
let payload;
try {
payload = JSON.parse(raw);
} catch {
payload = { action: raw, args: {} };
}
await postAction(payload.action, payload.args || {}, payload.target || "wall");
}
function connectWs() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
ws.addEventListener("open", () => {
wsDot.classList.add("ok");
wsText.textContent = "WebSocket 已连接";
});
ws.addEventListener("close", () => {
wsDot.classList.remove("ok");
wsText.textContent = "WebSocket 重连中";
setTimeout(connectWs, 1200);
});
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "state") {
state = message.payload.state || message.payload;
renderScenes();
}
if (["prepare_scene", "commit_scene"].includes(message.type)) {
state = message.payload.state;
renderScenes();
loadSyncStatus();
}
if (["ack", "heartbeat"].includes(message.type)) {
if (message.type === "ack") {
wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`;
}
loadSyncStatus();
}
});
}
refreshBtn.addEventListener("click", loadScenes);
runActionBtn.addEventListener("click", runCustomAction);
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
const args = button.dataset.args ? JSON.parse(button.dataset.args) : {};
postAction(button.dataset.action, args);
});
});
loadScenes();
loadSyncStatus();
connectWs();
setInterval(loadSyncStatus, 3000);
+216
View File
@@ -0,0 +1,216 @@
:root {
color-scheme: dark;
font-family: Inter, "Segoe UI", system-ui, sans-serif;
background: #101418;
color: #f8fafc;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(20, 184, 166, 0.13), transparent 280px),
#101418;
}
button,
a,
textarea {
font: inherit;
}
.shell {
width: min(1280px, calc(100vw - 40px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar,
.panelHead,
.syncRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.topbar {
margin-bottom: 22px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 28px;
letter-spacing: 0;
}
.topbar p {
margin-top: 8px;
color: #aeb7c2;
}
.connection {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.12);
}
.dot.ok {
background: #22c55e;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.8fr);
gap: 18px;
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.055);
padding: 18px;
}
.scenes {
grid-row: span 2;
}
h2 {
font-size: 17px;
}
button,
.linkGrid a {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: rgba(255, 255, 255, 0.07);
color: #f8fafc;
padding: 9px 12px;
cursor: pointer;
text-decoration: none;
}
button:hover,
.linkGrid a:hover {
border-color: rgba(20, 184, 166, 0.8);
}
.primary {
width: 100%;
margin-top: 12px;
background: #14b8a6;
border-color: #14b8a6;
color: #071211;
font-weight: 760;
}
.sceneList,
.syncStatus,
.linkGrid,
.actionGrid {
display: grid;
gap: 10px;
}
.sceneList,
.syncStatus,
.linkGrid {
margin-top: 14px;
}
.linkGrid {
grid-template-columns: 1fr;
}
.scene {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
padding: 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.scene.active {
border-color: rgba(20, 184, 166, 0.85);
background: rgba(20, 184, 166, 0.12);
}
.scene h3 {
font-size: 16px;
margin-bottom: 6px;
}
.scene p {
color: #aeb7c2;
font-size: 13px;
overflow-wrap: anywhere;
}
.syncRow {
padding: 9px 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.22);
color: #cbd5e1;
font-size: 13px;
}
.syncRow strong {
color: #f8fafc;
}
.actionGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 14px;
}
.customAction {
display: grid;
gap: 8px;
margin-top: 14px;
color: #cbd5e1;
}
textarea {
resize: vertical;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
background: rgba(0, 0, 0, 0.28);
color: #f8fafc;
padding: 10px;
}
@media (max-width: 980px) {
.grid,
.topbar {
grid-template-columns: 1fr;
display: grid;
}
.scenes {
grid-row: auto;
}
}