const { createApp } = Vue; createApp({ data() { return { scenes: [], state: null, syncSnapshot: null, wsConnected: false, wsText: "WebSocket", statusText: "正在连接控制服务...", busySceneId: null, actionInput: '{"action":"security.alert","args":{"text":"消防通道占用"}}', quickActions: [ { label: "上一页", action: "page.prev" }, { label: "下一页", action: "page.next" }, { label: "削峰模式", action: "energy.mode", args: { mode: "削峰" } }, { label: "保供模式", action: "energy.mode", args: { mode: "保供" } }, { label: "轨迹流动", action: "motion.mode", args: { mode: "flow" } }, { label: "脉冲扫描", action: "motion.mode", args: { mode: "pulse" } }, { label: "节点巡航", action: "motion.mode", args: { mode: "orbit" } }, { label: "新增告警", action: "security.alert", args: { text: "门禁异常联动" } }, { label: "核心区域", action: "scenario.focus", args: { target: "core" } }, { label: "边界区域", action: "scenario.focus", args: { target: "edge" } }, { label: "暂停时间轴", action: "timeline.pause" }, { label: "恢复时间轴", action: "timeline.resume" }, ], }; }, computed: { nodes() { return this.syncSnapshot?.nodes || []; }, targetTiles() { return this.syncSnapshot?.target_tiles || []; }, recentCommands() { return [...(this.syncSnapshot?.commands || [])].slice(-6).reverse(); }, connectedTiles() { return new Set(this.nodes.map((node) => node.tile_id)); }, previewLabels() { return { left: this.state ? `${this.state.active_scene_id} / v${this.state.version}` : "--", right: this.state ? `${this.state.active_scene_id} / v${this.state.version}` : "--", }; }, }, async mounted() { await Promise.all([this.loadScenes(), this.loadSyncStatus()]); this.connectWs(); setInterval(() => this.loadSyncStatus(), 2500); }, methods: { async loadScenes() { const res = await fetch("/api/scenes"); const data = await res.json(); this.scenes = data.scenes; this.state = data.state; this.statusText = `当前场景:${this.state.active_scene_id},版本 ${this.state.version}`; }, async loadSyncStatus() { const res = await fetch("/api/sync/status"); this.syncSnapshot = await res.json(); }, async switchScene(sceneId) { this.busySceneId = sceneId; try { const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" }); if (!res.ok) { alert(await res.text()); return; } const data = await res.json(); this.state = data.state; this.statusText = `计划切换:${this.state.active_scene_id},提交时间 ${this.formatClock(data.apply_at_ms)}`; await this.loadSyncStatus(); } finally { this.busySceneId = null; } }, async 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; } await this.loadSyncStatus(); }, async runCustomAction() { const raw = this.actionInput.trim(); if (!raw) return; let payload; try { payload = JSON.parse(raw); } catch { payload = { action: raw, args: {} }; } await this.postAction(payload.action, payload.args || {}, payload.target || "wall"); }, connectWs() { const protocol = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`); ws.addEventListener("open", () => { this.wsConnected = true; this.wsText = "WebSocket 已连接"; }); ws.addEventListener("close", () => { this.wsConnected = false; this.wsText = "WebSocket 重连中"; setTimeout(() => this.connectWs(), 1200); }); ws.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.type === "state") { this.state = message.payload.state || message.payload; this.statusText = `当前场景:${this.state.active_scene_id},版本 ${this.state.version}`; } if (message.type === "prepare_scene") { this.state = message.payload.state; this.statusText = `准备切换:${this.state.active_scene_id},等待输出端渲染,提交时间 ${this.formatClock(message.payload.apply_at_ms)}`; void this.loadSyncStatus(); } if (message.type === "commit_scene") { this.state = message.payload.state; this.statusText = `已放行:${this.state.active_scene_id},提交时间 ${this.formatClock(message.payload.apply_at_ms)}`; void this.loadSyncStatus(); } if (message.type === "ack") { this.wsText = `${message.payload.tile_id || "node"} ${message.payload.status}`; void this.loadSyncStatus(); } if (message.type === "heartbeat") { void this.loadSyncStatus(); } }); }, formatClock(ms) { if (!Number.isFinite(Number(ms))) return "--"; return new Date(Number(ms)).toLocaleTimeString("zh-CN", { hour12: false }); }, formatDelta(ms) { if (!Number.isFinite(Number(ms))) return "--"; const value = Math.round(Number(ms)); return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`; }, nodeMetric(node) { 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`; return `${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}`; }, commandTitle(command) { if (command.command_type === "commit_scene") { return `场景 ${command.payload?.state?.active_scene_id || command.command_id.slice(0, 8)}`; } if (command.command_type === "component_action") { return `动作 ${command.payload?.action || command.command_id.slice(0, 8)}`; } return `${command.command_type} ${command.command_id.slice(0, 8)}`; }, releaseText(command) { return command.released_at_ms ? `放行 ${this.formatDelta(command.released_at_ms - command.created_at_ms)}` : "等待真实输出端渲染"; }, missingTiles(command) { return this.targetTiles.filter((tileId) => !this.connectedTiles.has(tileId)); }, commandStateText(command) { if (command.complete) return "生产完成"; if (command.released_at_ms) return "已放行"; if (this.missingTiles(command).length) return "输出未连接"; return "等待 ACK"; }, commandStateClass(command) { if (command.complete) return "ok"; if (command.released_at_ms) return "ready"; return "wait"; }, commandNote(command) { const missing = this.missingTiles(command); if (missing.length) { return `预览已按计划更新;生产同步等待 ${missing.join(", ")} 输出端连接`; } return "预览已按计划更新;生产同步等待真实输出端 ACK"; }, latestAckForTile(command, tileId) { return [...(command.acks || [])].reverse().find((ack) => ack.tile_id === tileId); }, tileEchoStatus(command, tileId) { if (!this.connectedTiles.has(tileId)) return "disconnected"; return this.latestAckForTile(command, tileId)?.status || "waiting"; }, tileEchoClass(command, tileId) { const status = this.tileEchoStatus(command, tileId); if (["committed", "action_committed"].includes(status)) return "ok"; if (status === "prepared") return "ready"; if (["late", "error"].includes(status)) return "bad"; return "wait"; }, tileEchoLabel(command, tileId) { const status = this.tileEchoStatus(command, tileId); return { disconnected: "未连接", waiting: "等待", prepared: "已渲染", committed: "已输出", action_committed: "已执行", late: "迟到", error: "错误", }[status] || status; }, tileEchoDetail(command, tileId) { if (!this.connectedTiles.has(tileId)) return `打开 /output/${tileId} 才会 ACK`; const ack = this.latestAckForTile(command, tileId); if (!ack) return "等待真实输出端 ACK"; const rtt = ack.rtt_ms == null ? "--" : `${Math.round(ack.rtt_ms)}ms`; return `${this.formatClock(ack.server_received_ms)} / RTT ${rtt}`; }, }, }).mount("#adminApp");