Refactor LED control UI to Vue3
This commit is contained in:
@@ -7,25 +7,95 @@
|
||||
<link rel="stylesheet" href="/static/admin/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<main id="adminApp" class="shell" v-cloak>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>LED 大屏投放控制台</h1>
|
||||
<p id="stateText">正在连接控制服务...</p>
|
||||
<p>{{ statusText }}</p>
|
||||
</div>
|
||||
<div class="connection">
|
||||
<span id="wsDot" class="dot"></span>
|
||||
<span id="wsText">WebSocket</span>
|
||||
<span class="dot" :class="{ ok: wsConnected }"></span>
|
||||
<span>{{ wsText }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid">
|
||||
<section class="consoleLayout">
|
||||
<section class="panel previewPanel">
|
||||
<div class="panelHead">
|
||||
<div>
|
||||
<h2>画面回显</h2>
|
||||
<p class="panelHint">14880 x 3510 拼接预览,只读不 ACK</p>
|
||||
</div>
|
||||
<div class="previewLinks">
|
||||
<a href="/output/left" target="_blank">左输出</a>
|
||||
<a href="/output/right" target="_blank">右输出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="previewGrid">
|
||||
<article class="previewTile">
|
||||
<header>
|
||||
<strong>left</strong>
|
||||
<span>{{ previewLabels.left }}</span>
|
||||
</header>
|
||||
<iframe title="left 输出预览" src="/output/left?preview=1"></iframe>
|
||||
</article>
|
||||
<article class="previewTile">
|
||||
<header>
|
||||
<strong>right</strong>
|
||||
<span>{{ previewLabels.right }}</span>
|
||||
</header>
|
||||
<iframe title="right 输出预览" src="/output/right?preview=1"></iframe>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel scenes">
|
||||
<div class="panelHead">
|
||||
<h2>场景切换</h2>
|
||||
<button id="refreshBtn">刷新</button>
|
||||
<button type="button" @click="loadScenes">刷新</button>
|
||||
</div>
|
||||
<div id="sceneList" class="sceneList"></div>
|
||||
<div class="sceneList">
|
||||
<article
|
||||
v-for="scene in scenes"
|
||||
:key="scene.id"
|
||||
class="scene"
|
||||
:class="{ active: scene.id === state?.active_scene_id }"
|
||||
>
|
||||
<div>
|
||||
<h3>{{ scene.name }}</h3>
|
||||
<p>{{ scene.view }} | {{ scene.url }}</p>
|
||||
<p>{{ scene.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busySceneId === scene.id"
|
||||
@click="switchScene(scene.id)"
|
||||
>
|
||||
{{ busySceneId === scene.id ? "计划中" : "切换" }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panelHead">
|
||||
<h2>局部动作</h2>
|
||||
</div>
|
||||
<div class="actionGrid">
|
||||
<button
|
||||
v-for="action in quickActions"
|
||||
:key="action.action + JSON.stringify(action.args || {})"
|
||||
type="button"
|
||||
@click="postAction(action.action, action.args || {})"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="customAction">
|
||||
<span>自定义结构化动作</span>
|
||||
<textarea v-model="actionInput" rows="3"></textarea>
|
||||
</label>
|
||||
<button type="button" class="primary" @click="runCustomAction">同步执行</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -38,30 +108,54 @@
|
||||
<a href="/api/sync/status" target="_blank">同步状态</a>
|
||||
<a href="/docs" target="_blank">API 文档</a>
|
||||
</div>
|
||||
<div id="syncStatus" class="syncStatus"></div>
|
||||
<div class="syncStatus">
|
||||
<div class="syncRow">
|
||||
<span>输出节点</span>
|
||||
<strong>{{ nodes.length }}/{{ targetTiles.length }}</strong>
|
||||
</div>
|
||||
<div v-for="node in nodes" :key="node.node_id" class="syncRow">
|
||||
<span>{{ node.tile_id }} {{ node.node_id.slice(0, 16) }}</span>
|
||||
<span>{{ nodeMetric(node) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panelHead">
|
||||
<h2>局部动作</h2>
|
||||
<div class="panelHead compact">
|
||||
<div>
|
||||
<h2>生产命令回显</h2>
|
||||
<p class="panelHint">真实输出端 ACK,不代表上方预览</p>
|
||||
</div>
|
||||
</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 class="commandEcho">
|
||||
<div v-if="recentCommands.length === 0" class="emptyEcho">暂无命令回显</div>
|
||||
<article v-for="command in recentCommands" :key="command.command_id" class="echoCard">
|
||||
<div class="echoHead">
|
||||
<div>
|
||||
<h4>{{ commandTitle(command) }}</h4>
|
||||
<p>{{ command.command_id.slice(0, 12) }} | 计划 {{ formatClock(command.apply_at_ms) }} | {{ releaseText(command) }}</p>
|
||||
<p>{{ commandNote(command) }}</p>
|
||||
</div>
|
||||
<span class="echoState" :class="commandStateClass(command)">{{ commandStateText(command) }}</span>
|
||||
</div>
|
||||
<div class="tileEchoGrid">
|
||||
<div
|
||||
v-for="tileId in targetTiles"
|
||||
:key="command.command_id + tileId"
|
||||
class="tileEcho"
|
||||
:class="tileEchoClass(command, tileId)"
|
||||
>
|
||||
<span>{{ tileId }}</span>
|
||||
<strong>{{ tileEchoLabel(command, tileId) }}</strong>
|
||||
<small>{{ tileEchoDetail(command, tileId) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</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/vendor/vue.global.prod.js"></script>
|
||||
<script src="/static/admin/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,155 +1,250 @@
|
||||
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");
|
||||
const { createApp } = Vue;
|
||||
|
||||
let scenes = [];
|
||||
let state = null;
|
||||
let syncSnapshot = null;
|
||||
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" },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
async function loadScenes() {
|
||||
const res = await fetch("/api/scenes");
|
||||
const data = await res.json();
|
||||
scenes = data.scenes;
|
||||
state = data.state;
|
||||
renderScenes();
|
||||
}
|
||||
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 function loadSyncStatus() {
|
||||
const res = await fetch("/api/sync/status");
|
||||
syncSnapshot = await res.json();
|
||||
renderSyncStatus();
|
||||
}
|
||||
async mounted() {
|
||||
await Promise.all([this.loadScenes(), this.loadSyncStatus()]);
|
||||
this.connectWs();
|
||||
setInterval(() => this.loadSyncStatus(), 2500);
|
||||
},
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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}`;
|
||||
},
|
||||
|
||||
function renderSyncStatus() {
|
||||
if (!syncSnapshot) return;
|
||||
const nodes = syncSnapshot.nodes || [];
|
||||
const commands = (syncSnapshot.commands || []).slice(-5).reverse();
|
||||
const rows = [];
|
||||
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
|
||||
async loadSyncStatus() {
|
||||
const res = await fetch("/api/sync/status");
|
||||
this.syncSnapshot = await res.json();
|
||||
},
|
||||
|
||||
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>`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
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>`);
|
||||
}
|
||||
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();
|
||||
},
|
||||
|
||||
syncStatus.innerHTML = rows.join("");
|
||||
}
|
||||
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");
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
formatClock(ms) {
|
||||
if (!Number.isFinite(Number(ms))) return "--";
|
||||
return new Date(Number(ms)).toLocaleTimeString("zh-CN", { hour12: false });
|
||||
},
|
||||
|
||||
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");
|
||||
}
|
||||
formatDelta(ms) {
|
||||
if (!Number.isFinite(Number(ms))) return "--";
|
||||
const value = Math.round(Number(ms));
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
|
||||
},
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
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}`;
|
||||
},
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
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)}`;
|
||||
},
|
||||
|
||||
loadScenes();
|
||||
loadSyncStatus();
|
||||
connectWs();
|
||||
setInterval(loadSyncStatus, 2500);
|
||||
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");
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[v-cloak] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@@ -24,9 +28,9 @@ textarea {
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1280px, calc(100vw - 40px));
|
||||
width: min(1760px, calc(100vw - 24px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
padding: 16px 0 28px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
@@ -38,8 +42,12 @@ textarea {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panelHead.compact {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
margin-bottom: 22px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -50,7 +58,7 @@ p {
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@@ -78,27 +86,42 @@ h1 {
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
|
||||
}
|
||||
|
||||
.grid {
|
||||
.consoleLayout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.8fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.previewPanel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
padding: 18px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.scenes {
|
||||
grid-row: span 2;
|
||||
.panelMeta {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panelHint {
|
||||
margin-top: 4px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
button,
|
||||
.linkGrid a {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -115,6 +138,58 @@ button:hover,
|
||||
border-color: rgba(20, 184, 166, 0.8);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.previewGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 8px;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
.previewTile {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
.previewTile header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.previewTile strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.previewTile a {
|
||||
color: #5eead4;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.previewTile iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 7440 / 3510;
|
||||
border: 0;
|
||||
background: #05070a;
|
||||
}
|
||||
|
||||
.primary {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
@@ -127,7 +202,8 @@ button:hover,
|
||||
.sceneList,
|
||||
.syncStatus,
|
||||
.linkGrid,
|
||||
.actionGrid {
|
||||
.actionGrid,
|
||||
.commandEcho {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -135,19 +211,19 @@ button:hover,
|
||||
.sceneList,
|
||||
.syncStatus,
|
||||
.linkGrid {
|
||||
margin-top: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.linkGrid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.scene {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
@@ -159,13 +235,13 @@ button:hover,
|
||||
}
|
||||
|
||||
.scene h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.scene p {
|
||||
color: #aeb7c2;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -181,15 +257,105 @@ button:hover,
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.echoCard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.echoHead,
|
||||
.tileEcho {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.echoHead h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.echoHead p,
|
||||
.tileEcho small,
|
||||
.emptyEcho {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.echoState,
|
||||
.tileEcho strong {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.echoState.ok,
|
||||
.tileEcho.ok strong {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.echoState.ready,
|
||||
.tileEcho.ready strong {
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
color: #5eead4;
|
||||
}
|
||||
|
||||
.echoState.wait,
|
||||
.tileEcho.wait strong {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tileEcho.bad strong {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.tileEchoGrid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tileEcho {
|
||||
min-height: 48px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.tileEcho span {
|
||||
min-width: 44px;
|
||||
color: #f8fafc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tileEcho small {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.emptyEcho {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.actionGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin-top: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.customAction {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
margin-top: 10px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
@@ -203,14 +369,44 @@ textarea {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1240px) {
|
||||
.consoleLayout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.previewPanel {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid,
|
||||
.shell {
|
||||
width: min(100vw - 12px, 1760px);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.consoleLayout,
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.scenes {
|
||||
grid-row: auto;
|
||||
.previewTile header {
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.linkGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: portrait) and (max-width: 680px) {
|
||||
.previewGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user