251 lines
8.9 KiB
JavaScript
251 lines
8.9 KiB
JavaScript
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");
|