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 = `

${scene.name}

${scene.view} | ${scene.url}

${scene.description ?? ""}

`; 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(-5).reverse(); const rows = []; rows.push(`
输出节点${nodes.length}/${syncSnapshot.target_tiles.length}
`); 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(`
${node.tile_id} ${node.node_id.slice(0, 16)}${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}
`); } for (const command of commands) { const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK"; rows.push(`
${command.command_type} ${command.command_id.slice(0, 8)}${command.complete ? "完成" : "进行中"} | ${ackText}
`); } 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 (message.type === "ack") { wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`; loadSyncStatus(); } if (message.type === "heartbeat") { 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, 2500);