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;
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#0b1118"/>
<rect x="8" y="16" width="48" height="30" rx="3" fill="#14b8a6"/>
<rect x="12" y="20" width="18" height="22" fill="#0f172a"/>
<rect x="34" y="20" width="18" height="22" fill="#f97316"/>
</svg>

After

Width:  |  Height:  |  Size: 316 B

+67
View File
@@ -0,0 +1,67 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LED Wall Output</title>
<link rel="stylesheet" href="/static/output/styles.css" />
</head>
<body>
<main id="viewport" class="viewport">
<section id="wall" class="wall" aria-label="LED wall output">
<canvas id="motionCanvas" class="motionCanvas"></canvas>
<div class="gridLayer"></div>
<header class="wallHeader">
<div>
<p id="eyebrow" class="eyebrow">Unified Timeline Rendering</p>
<h1 id="sceneTitle">LED 大屏投放平台</h1>
</div>
<div class="metrics">
<strong id="clock">--:--:--</strong>
<span id="version">v0</span>
</div>
</header>
<section class="scene active" data-view="overview">
<div class="headline">
<h2>双 GPU 输出,统一时间轴</h2>
<p>左右服务器各自渲染半屏,但所有场景、动画、地图相机、视频时间都由同一个服务端时间轴驱动。</p>
</div>
<div id="overviewKpis" class="kpiGrid"></div>
<div id="overviewBars" class="bars"></div>
</section>
<section class="scene" data-view="energy">
<div class="headline">
<h2>能源驾驶舱</h2>
<p>适合 ECharts、Canvas、WebGL 地图和 Three.js 数字孪生。真实项目中应使用 camera.setViewOffset 做 tile-aware 渲染。</p>
</div>
<div class="energyMap">
<div class="flowLine lineA"></div>
<div class="flowLine lineB"></div>
<div class="flowNode nodeA">园区供电</div>
<div class="flowNode nodeB">储能系统</div>
<div class="flowNode nodeC">负载中心</div>
</div>
<div class="sideStats">
<article><span>实时功率</span><strong id="powerValue">8.42 MW</strong></article>
<article><span>负载趋势</span><strong id="trendValue">+3.8%</strong></article>
<article><span>调度策略</span><strong id="modeValue">均衡</strong></article>
</div>
</section>
<section class="scene" data-view="security">
<div class="headline">
<h2>安防态势</h2>
<p>视频墙、告警卡片和地图联动都通过结构化动作同步触发,不在输出端执行任意远程脚本。</p>
</div>
<div id="cameraGrid" class="cameraGrid"></div>
<div id="alertRail" class="alertRail"></div>
</section>
</section>
<aside id="hud" class="hud"></aside>
</main>
<script src="/static/output/main.js"></script>
</body>
</html>
+325
View File
@@ -0,0 +1,325 @@
const APP_VERSION = "1.0.0";
const hud = document.querySelector("#hud");
const wall = document.querySelector("#wall");
const canvas = document.querySelector("#motionCanvas");
const ctx = canvas.getContext("2d", { alpha: true });
const clock = document.querySelector("#clock");
const version = document.querySelector("#version");
const sceneTitle = document.querySelector("#sceneTitle");
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
const sceneNames = {
overview: "LED 大屏投放平台",
energy: "能源驾驶舱",
security: "安防态势",
};
let ws = null;
let tile = null;
let state = null;
let paused = false;
let pageIndex = 0;
let clockOffsetMs = 0;
let bestRttMs = Number.POSITIVE_INFINITY;
let pendingCommands = new Map();
let frameCount = 0;
let droppedFrames = 0;
let lastFrameAt = performance.now();
let lastFpsAt = performance.now();
let fps = 0;
let frameTimeMs = 0;
let nodeId = sessionStorage.getItem("led-platform-node-id");
if (!nodeId) {
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
nodeId = `${tileId}-${randomId}`;
sessionStorage.setItem("led-platform-node-id", nodeId);
}
function clientNowMs() {
return Date.now();
}
function serverNowMs() {
return clientNowMs() + clockOffsetMs;
}
async function bootstrap() {
const res = await fetch(`/api/bootstrap/${tileId}`);
const data = await res.json();
tile = data.tile;
applyState(data.state);
buildStaticContent();
layout();
connectWs();
requestAnimationFrame(renderLoop);
}
function layout() {
if (!tile) return;
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
document.documentElement.style.setProperty("--wall-width", `${tile.wall_width}px`);
document.documentElement.style.setProperty("--wall-height", `${tile.wall_height}px`);
document.documentElement.style.setProperty("--scale", `${scale}`);
document.documentElement.style.setProperty("--offset-x", `${-tile.x * scale}px`);
document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`);
canvas.width = tile.wall_width;
canvas.height = tile.wall_height;
updateHud();
}
function buildStaticContent() {
document.querySelector("#overviewKpis").innerHTML = [
["渲染模式", "Local GPU"],
["同步方式", "Timeline"],
["逻辑宽度", "14,880"],
["逻辑高度", "3,510"],
["左/右分区", "7,440"],
["目标帧率", "60 FPS"],
].map(([label, value]) => `<article class="kpi"><span>${label}</span><strong>${value}</strong></article>`).join("");
const bars = document.querySelector("#overviewBars");
bars.innerHTML = "";
for (let i = 0; i < 32; i += 1) {
const bar = document.createElement("div");
bar.className = "bar";
bar.style.height = `${180 + ((i * 137) % 760)}px`;
bars.append(bar);
}
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
const id = String(index + 1).padStart(2, "0");
return `<article class="camera"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
}).join("");
document.querySelector("#alertRail").innerHTML = [
"北侧通道人员聚集",
"机房门禁异常",
"消防通道占用",
"视频质量波动",
].map((item) => `<article class="alert">${item}</article>`).join("");
}
function applyState(nextState) {
state = nextState;
version.textContent = `v${state.version}`;
setScene(state.active_view);
}
function setScene(view) {
const normalized = view || "overview";
document.querySelectorAll(".scene").forEach((scene) => {
scene.classList.toggle("active", scene.dataset.view === normalized);
});
sceneTitle.textContent = sceneNames[normalized] || sceneNames.overview;
pulse();
}
function scheduleAt(applyAtMs, callback) {
const leadTimeMs = applyAtMs - serverNowMs();
const late = leadTimeMs < 80;
setTimeout(() => callback(late), Math.max(0, leadTimeMs));
}
function prepareScene(message) {
pendingCommands.set(message.command_id, message.payload);
sendAck(message, "prepared");
}
function commitScene(message) {
const payload = pendingCommands.get(message.command_id) || message.payload;
scheduleAt(payload.apply_at_ms, (late) => {
applyState(payload.state);
pendingCommands.delete(message.command_id);
sendAck(message, late ? "late" : "committed");
});
}
function runComponentAction(message) {
const { action, args, apply_at_ms: applyAtMs } = message.payload;
scheduleAt(applyAtMs, (late) => {
applyAction(action, args || {});
sendAck(message, late ? "late" : "action_committed");
});
}
function applyAction(action, args) {
if (action === "page.next") {
pageIndex += 1;
rotateValues();
} else if (action === "page.prev") {
pageIndex = Math.max(0, pageIndex - 1);
rotateValues();
} else if (action === "energy.mode") {
document.querySelector("#modeValue").textContent = args.mode || "削峰";
} else if (action === "security.alert") {
const rail = document.querySelector("#alertRail");
const alert = document.createElement("article");
alert.className = "alert pulse";
alert.textContent = args.text || "新增联动告警";
rail.prepend(alert);
while (rail.children.length > 5) rail.lastElementChild.remove();
} else if (action === "timeline.pause") {
paused = true;
} else if (action === "timeline.resume") {
paused = false;
}
pulse();
}
function rotateValues() {
const values = [
["8.42 MW", "+3.8%", "均衡"],
["7.91 MW", "-1.2%", "削峰"],
["9.08 MW", "+6.1%", "保供"],
][pageIndex % 3];
document.querySelector("#powerValue").textContent = values[0];
document.querySelector("#trendValue").textContent = values[1];
document.querySelector("#modeValue").textContent = values[2];
}
function timelineMs() {
if (!state) return 0;
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
}
function renderLoop(now) {
frameTimeMs = now - lastFrameAt;
if (frameTimeMs > 40) droppedFrames += 1;
frameCount += 1;
if (now - lastFpsAt >= 1000) {
fps = frameCount * 1000 / (now - lastFpsAt);
frameCount = 0;
lastFpsAt = now;
}
lastFrameAt = now;
drawMotion(paused ? 0 : timelineMs());
updateAnimatedBars(paused ? 0 : timelineMs());
updateHud();
requestAnimationFrame(renderLoop);
}
function drawMotion(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 0.72;
for (let i = 0; i < 24; i += 1) {
const phase = (t / 1000) + i * 0.42;
const x = (Math.sin(phase * 0.42) * 0.5 + 0.5) * canvas.width;
const y = (Math.cos(phase * 0.36) * 0.5 + 0.5) * canvas.height;
const r = 80 + (i % 5) * 28;
const grad = ctx.createRadialGradient(x, y, 0, x, y, r * 5);
grad.addColorStop(0, i % 2 ? "rgba(249,115,22,0.23)" : "rgba(20,184,166,0.25)");
grad.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, r * 5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function updateAnimatedBars(t) {
document.querySelectorAll(".bar").forEach((bar, index) => {
const scale = 0.82 + (Math.sin(t / 760 + index * 0.36) + 1) * 0.16;
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
});
}
function pulse() {
wall.classList.remove("pulse");
requestAnimationFrame(() => wall.classList.add("pulse"));
}
function updateHud() {
if (!tile) return;
const rtt = Number.isFinite(bestRttMs) ? Math.round(bestRttMs) : "--";
const offset = `${clockOffsetMs >= 0 ? "+" : ""}${Math.round(clockOffsetMs)}ms`;
hud.textContent = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt}ms | offset ${offset}`;
}
function sendAck(message, status) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
type: "ack",
command_id: message.command_id,
node_id: nodeId,
tile_id: tile.id,
status,
client_time_ms: clientNowMs(),
server_estimated_ms: serverNowMs(),
clock_offset_ms: clockOffsetMs,
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
}));
}
function syncClock() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: "clock_ping", node_id: nodeId, client_send_ms: clientNowMs() }));
}
function handleClockPong(payload) {
const receiveMs = clientNowMs();
const sendMs = payload.client_send_ms;
if (typeof sendMs !== "number") return;
const rtt = receiveMs - sendMs;
const estimatedServerAtReceive = payload.server_time_ms + rtt / 2;
const nextOffset = estimatedServerAtReceive - receiveMs;
if (rtt < bestRttMs) {
bestRttMs = rtt;
clockOffsetMs = nextOffset;
} else {
clockOffsetMs = clockOffsetMs * 0.85 + nextOffset * 0.15;
}
}
function sendTelemetry() {
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
ws.send(JSON.stringify({
type: "telemetry",
node_id: nodeId,
tile_id: tile.id,
fps,
frame_time_ms: frameTimeMs,
dropped_frames: droppedFrames,
}));
}
function connectWs() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${location.host}/ws/output/${tile.id}`);
ws.addEventListener("open", () => {
ws.send(JSON.stringify({
type: "hello",
node_id: nodeId,
tile_id: tile.id,
app_version: APP_VERSION,
user_agent: navigator.userAgent,
}));
syncClock();
});
ws.addEventListener("close", () => setTimeout(connectWs, 1200));
ws.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "clock_pong") handleClockPong(message.payload);
if (message.type === "state") commitScene({ command_id: message.command_id, payload: message.payload });
if (message.type === "prepare_scene") prepareScene(message);
if (message.type === "commit_scene") commitScene(message);
if (message.type === "component_action") runComponentAction(message);
});
}
function tickClock() {
clock.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
window.addEventListener("resize", layout);
window.addEventListener("keydown", (event) => {
if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden;
});
bootstrap();
tickClock();
setInterval(tickClock, 1000);
setInterval(syncClock, 2500);
setInterval(sendTelemetry, 1000);
+322
View File
@@ -0,0 +1,322 @@
:root {
color-scheme: dark;
font-family: Inter, "Segoe UI", system-ui, sans-serif;
background: #000;
color: #f8fafc;
--wall-width: 14880px;
--wall-height: 3510px;
--scale: 1;
--offset-x: 0px;
--offset-y: 0px;
}
* {
box-sizing: border-box;
}
html,
body,
.viewport {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
.viewport {
position: fixed;
inset: 0;
background: #020407;
}
.wall {
position: absolute;
left: 0;
top: 0;
width: var(--wall-width);
height: var(--wall-height);
overflow: hidden;
transform-origin: 0 0;
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
background:
linear-gradient(90deg, rgba(20, 184, 166, 0.14), transparent 34%, rgba(249, 115, 22, 0.12)),
#071018;
}
.motionCanvas,
.gridLayer {
position: absolute;
inset: 0;
}
.gridLayer {
background-image:
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
background-size: 240px 240px;
opacity: 0.72;
}
.wallHeader {
position: absolute;
z-index: 3;
left: 360px;
right: 360px;
top: 180px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 120px;
}
h1,
h2,
p {
margin: 0;
letter-spacing: 0;
}
.eyebrow {
margin-bottom: 36px;
color: #5eead4;
font-size: 58px;
text-transform: uppercase;
}
h1 {
font-size: 178px;
line-height: 1;
}
.metrics {
display: grid;
justify-items: end;
gap: 24px;
}
.metrics strong {
font-size: 108px;
line-height: 1;
}
.metrics span {
color: #cbd5e1;
font-size: 48px;
}
.scene {
position: absolute;
z-index: 2;
inset: 620px 360px 240px;
opacity: 0;
pointer-events: none;
transform: translateY(50px);
transition: opacity 360ms ease, transform 360ms ease;
}
.scene.active {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.headline {
max-width: 5900px;
}
.headline h2 {
font-size: 156px;
line-height: 1.05;
}
.headline p {
margin-top: 42px;
color: #cbd5e1;
font-size: 62px;
line-height: 1.35;
}
.kpiGrid {
margin-top: 140px;
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 72px;
}
.kpi,
.sideStats article,
.camera {
border: 3px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: rgba(255,255,255,0.055);
}
.kpi {
min-height: 500px;
padding: 70px;
}
.kpi span,
.sideStats span {
display: block;
color: #aeb7c2;
font-size: 58px;
}
.kpi strong,
.sideStats strong {
display: block;
margin-top: 46px;
font-size: 130px;
line-height: 1;
}
.bars {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 960px;
display: grid;
grid-template-columns: repeat(32, 1fr);
gap: 24px;
align-items: end;
}
.bar {
min-height: 100px;
border-radius: 8px 8px 0 0;
background: linear-gradient(180deg, #14b8a6, #f97316);
transform-origin: bottom;
}
.energyMap {
position: absolute;
left: 0;
top: 780px;
width: 8600px;
height: 1760px;
}
.flowLine {
position: absolute;
height: 28px;
border-radius: 8px;
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
box-shadow: 0 0 80px rgba(20,184,166,0.42);
}
.lineA {
left: 1100px;
top: 690px;
width: 5200px;
transform: rotate(7deg);
}
.lineB {
left: 2300px;
top: 1050px;
width: 4200px;
transform: rotate(-10deg);
}
.flowNode {
position: absolute;
width: 980px;
height: 420px;
display: grid;
place-items: center;
border: 5px solid rgba(94,234,212,0.55);
border-radius: 8px;
background: rgba(8,20,28,0.92);
font-size: 76px;
font-weight: 760;
}
.nodeA { left: 220px; top: 520px; }
.nodeB { left: 3600px; top: 220px; }
.nodeC { left: 7020px; top: 900px; }
.sideStats {
position: absolute;
right: 0;
top: 690px;
width: 4700px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 64px;
}
.sideStats article {
min-height: 610px;
padding: 78px;
}
.cameraGrid {
position: absolute;
left: 0;
top: 760px;
width: 9200px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 52px;
}
.camera {
height: 700px;
padding: 52px;
display: grid;
align-content: space-between;
background:
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
rgba(255,255,255,0.055);
}
.camera strong {
font-size: 72px;
}
.camera span {
color: #cbd5e1;
font-size: 48px;
}
.alertRail {
position: absolute;
right: 0;
top: 740px;
width: 4300px;
display: grid;
gap: 44px;
}
.alert {
padding: 54px 64px;
border-left: 18px solid #f97316;
border-radius: 8px;
background: rgba(255,255,255,0.065);
font-size: 60px;
}
.pulse {
animation: flash 650ms ease;
}
@keyframes flash {
0% { outline: 16px solid rgba(250,204,21,0.78); }
100% { outline: 0 solid rgba(250,204,21,0); }
}
.hud {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 10;
max-width: min(860px, calc(100vw - 24px));
padding: 8px 10px;
border-radius: 6px;
background: rgba(0,0,0,0.68);
color: rgba(255,255,255,0.78);
font-size: 12px;
pointer-events: none;
}