Refactor LED control UI to Vue3
This commit is contained in:
@@ -96,3 +96,8 @@ python -m compileall led_platform tests
|
|||||||
node --check led_platform/web/static/output/main.js
|
node --check led_platform/web/static/output/main.js
|
||||||
node --check led_platform/web/static/admin/main.js
|
node --check led_platform/web/static/admin/main.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [使用文档](docs/usage.md)
|
||||||
|
- [生产架构说明](docs/production-architecture.md)
|
||||||
|
|||||||
@@ -24,6 +24,30 @@
|
|||||||
"view": "security",
|
"view": "security",
|
||||||
"description": "\u89c6\u9891\u5899\u3001\u544a\u8b66\u3001\u5730\u56fe\u8054\u52a8\u573a\u666f",
|
"description": "\u89c6\u9891\u5899\u3001\u544a\u8b66\u3001\u5730\u56fe\u8054\u52a8\u573a\u666f",
|
||||||
"preload": ["camera-grid", "security-map"]
|
"preload": ["camera-grid", "security-map"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "command",
|
||||||
|
"name": "\u6307\u6325\u8c03\u5ea6",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "command",
|
||||||
|
"description": "\u4e8b\u4ef6\u6307\u6325\u3001\u4efb\u52a1\u6d3e\u53d1\u548c\u8de8\u533a\u534f\u540c\u573a\u666f",
|
||||||
|
"preload": ["command-network", "dispatch-tasks"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "transport",
|
||||||
|
"name": "\u4ea4\u901a\u8fd0\u884c",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "transport",
|
||||||
|
"description": "\u8f66\u6d41\u3001\u8def\u7f51\u3001\u7ad9\u70b9\u8fd0\u884c\u548c\u8fd0\u529b\u8c03\u5ea6\u573a\u666f",
|
||||||
|
"preload": ["traffic-flow", "station-load"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dataflow",
|
||||||
|
"name": "\u6570\u636e\u4e2d\u67a2",
|
||||||
|
"url": "/wall-app",
|
||||||
|
"view": "dataflow",
|
||||||
|
"description": "\u6570\u636e\u6d41\u5165\u3001\u8ba1\u7b97\u96c6\u7fa4\u3001AI \u5206\u6790\u548c\u8f93\u51fa\u6307\u6807\u573a\u666f",
|
||||||
|
"preload": ["data-pipeline", "ai-cluster"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-62
@@ -2,48 +2,43 @@
|
|||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
将一个前端大屏应用稳定投放到 `14880 x 3510` LED 大屏。
|
本项目用于把一个逻辑尺寸为 `14880 x 3510` 的 LED 大屏内容,拆分到两台 GPU 输出服务器本地渲染。
|
||||||
|
|
||||||
硬件约束:
|
生产形态:
|
||||||
|
|
||||||
- 两台 GPU 服务器。
|
- 控制服务运行 FastAPI,负责场景、动作、时间同步、ACK、状态监控。
|
||||||
- 每台服务器 4 路 4K 输出。
|
- 左 GPU 服务器打开 `/output/left`,只显示左半屏 tile。
|
||||||
- 两台服务器品牌或 GPU 可能不同,无法依赖硬件级 GPU 同步。
|
- 右 GPU 服务器打开 `/output/right`,只显示右半屏 tile。
|
||||||
|
- 每台 GPU 服务器输出 4 路 4K 到拼控或 LED 控制器。
|
||||||
|
- 拼控或 LED 控制器完成最终物理拼接。
|
||||||
|
|
||||||
因此系统目标不是让两张 GPU 每一帧硬同步,而是:
|
WebSocket 不传大视频流,只传状态、命令、时间、ACK 和性能指标。画面由两台 GPU 服务器本地渲染。
|
||||||
|
|
||||||
- 统一业务状态。
|
## 逻辑拓扑
|
||||||
- 统一提交时间。
|
|
||||||
- 统一动画时间轴。
|
|
||||||
- 输出端本地渲染。
|
|
||||||
- 拼控完成物理拼接和输入对齐。
|
|
||||||
|
|
||||||
## 推荐架构
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
控制服务 FastAPI
|
控制台 /
|
||||||
- 场景管理
|
http://control:8000/
|
||||||
- WebSocket 同步
|
|
|
||||||
- 时间校准
|
v
|
||||||
- ACK 追踪
|
FastAPI 控制服务
|
||||||
- 性能监控
|
场景管理 / WebSocket Hub / SyncCoordinator / 状态接口
|
||||||
|
| |
|
||||||
左 GPU 服务器
|
prepare/commit/action status/telemetry/ACK
|
||||||
- 打开 /output/left
|
| |
|
||||||
- 渲染完整大屏应用的 left tile
|
-------------------------------
|
||||||
- 4 路 4K 输出到拼控
|
| |
|
||||||
|
v v
|
||||||
右 GPU 服务器
|
左 GPU 输出服务器 右 GPU 输出服务器
|
||||||
- 打开 /output/right
|
/output/left /output/right
|
||||||
- 渲染完整大屏应用的 right tile
|
left tile 本地渲染 right tile 本地渲染
|
||||||
- 4 路 4K 输出到拼控
|
4 路 4K 输出 4 路 4K 输出
|
||||||
|
| |
|
||||||
拼控 / LED 控制器
|
---------> 拼控 / LED 控制器 <-
|
||||||
- 接收 8 路 4K
|
14880 x 3510
|
||||||
- 按物理坐标拼接成 14880 x 3510
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 左右屏如何分开
|
## Tile 切分
|
||||||
|
|
||||||
完整逻辑画面:
|
完整逻辑画面:
|
||||||
|
|
||||||
@@ -56,13 +51,19 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
left:
|
left:
|
||||||
x=0, y=0, width=7440, height=3510
|
x=0
|
||||||
|
y=0
|
||||||
|
width=7440
|
||||||
|
height=3510
|
||||||
|
|
||||||
right:
|
right:
|
||||||
x=7440, y=0, width=7440, height=3510
|
x=7440
|
||||||
|
y=0
|
||||||
|
width=7440
|
||||||
|
height=3510
|
||||||
```
|
```
|
||||||
|
|
||||||
输出端页面创建完整逻辑大屏坐标系,然后根据自己的 tile 做视口偏移。真实 Three.js 项目中,应使用:
|
输出端页面使用完整逻辑大屏坐标系,再根据自己的 tile 做视口偏移。Three.js 项目中建议使用:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
camera.setViewOffset(
|
camera.setViewOffset(
|
||||||
@@ -71,19 +72,19 @@ camera.setViewOffset(
|
|||||||
tile.x,
|
tile.x,
|
||||||
tile.y,
|
tile.y,
|
||||||
tile.width,
|
tile.width,
|
||||||
tile.height
|
tile.height,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## 每台服务器 4 路 4K
|
## 每台服务器 4 路 4K
|
||||||
|
|
||||||
每台服务器建议配置为 `2 x 2` 逻辑桌面:
|
每台 GPU 服务器建议配置为 `2 x 2` 逻辑桌面:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
7680 x 4320
|
7680 x 4320
|
||||||
```
|
```
|
||||||
|
|
||||||
四路输出:
|
四路输出映射:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1: x=0, y=0, 3840 x 2160
|
1: x=0, y=0, 3840 x 2160
|
||||||
@@ -96,35 +97,50 @@ camera.setViewOffset(
|
|||||||
|
|
||||||
## 同步协议
|
## 同步协议
|
||||||
|
|
||||||
场景切换:
|
### 场景切换
|
||||||
|
|
||||||
|
控制台调用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/scenes/{scene_id}/switch
|
POST /api/scenes/{scene_id}/switch
|
||||||
```
|
```
|
||||||
|
|
||||||
服务端广播:
|
服务端生成同一个 `command_id`,先广播:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
prepare_scene(command_id, apply_at_ms)
|
prepare_scene(command_id, apply_at_ms, state, scene)
|
||||||
commit_scene(command_id, apply_at_ms)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
输出端流程:
|
输出端收到 `prepare_scene` 后:
|
||||||
|
|
||||||
|
1. 预加载资源。
|
||||||
|
2. 等待至少两个 RAF,让浏览器完成布局和首帧准备。
|
||||||
|
3. 如果存在 `window.ledPlatformPrepareScene`,等待业务方的真实预渲染/超分完成。
|
||||||
|
4. 回 ACK:`status=prepared`。
|
||||||
|
|
||||||
|
服务端 `SyncCoordinator` 等 `target_tiles` 中的 `left` 和 `right` 都返回 `prepared` 后,才广播:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 收到 prepare,记录命令,可预加载资源。
|
commit_scene(command_id, apply_at_ms, state, scene)
|
||||||
2. 收到 commit,等到 apply_at_ms。
|
|
||||||
3. 到点提交场景。
|
|
||||||
4. 回 ACK。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
局部动作:
|
输出端收到 `commit_scene` 后,等到统一的 `apply_at_ms` 再切换画面,并回 ACK:
|
||||||
|
|
||||||
|
```text
|
||||||
|
status=committed
|
||||||
|
```
|
||||||
|
|
||||||
|
这样谁渲染慢就等谁,两个输出端都准备好后才同步放行。
|
||||||
|
|
||||||
|
### 局部动作
|
||||||
|
|
||||||
|
控制台调用:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
POST /api/actions
|
POST /api/actions
|
||||||
```
|
```
|
||||||
|
|
||||||
支持结构化动作:
|
支持动作:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
page.next
|
page.next
|
||||||
@@ -135,37 +151,93 @@ timeline.pause
|
|||||||
timeline.resume
|
timeline.resume
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关键渲染原则
|
局部动作目前直接按 `apply_at_ms` 调度并回 ACK:
|
||||||
|
|
||||||
所有动画、Three.js、地图和视频都应基于统一时间轴:
|
```text
|
||||||
|
status=action_committed
|
||||||
|
```
|
||||||
|
|
||||||
|
如果后续某些局部动作也需要超分或重资源准备,可以复用场景切换的 prepare/barrier/commit 模式。
|
||||||
|
|
||||||
|
## 画面回显
|
||||||
|
|
||||||
|
控制台内置两个只读预览 iframe,并把它们无缝拼成一块完整画面:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/output/left?preview=1
|
||||||
|
/output/right?preview=1
|
||||||
|
```
|
||||||
|
|
||||||
|
预览模式通过 `/ws/admin` 接收状态和命令,不连接 `/ws/output/{tile_id}`,因此:
|
||||||
|
|
||||||
|
- 不注册为真实输出节点。
|
||||||
|
- 不发送 `prepared`、`committed`、`telemetry` ACK。
|
||||||
|
- 不会提前释放同步 barrier。
|
||||||
|
- 只用于控制台观察画面。
|
||||||
|
|
||||||
|
真实生产输出端仍然打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/output/left
|
||||||
|
/output/right
|
||||||
|
```
|
||||||
|
|
||||||
|
## 时间同步
|
||||||
|
|
||||||
|
输出端通过 `clock_ping` / `clock_pong` 估算服务端时间:
|
||||||
|
|
||||||
|
```text
|
||||||
|
serverNowMs = Date.now() + serverOffsetFromDateMs
|
||||||
|
```
|
||||||
|
|
||||||
|
RTT 使用 `performance.now()` 估算,服务端时间偏移使用客户端 wall clock 的发送时间和返回时间中点估算。
|
||||||
|
|
||||||
|
渲染和动画应基于统一时间轴:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const t = serverNowMs() - sceneStartedAtMs;
|
const t = serverNowMs() - sceneStartedAtMs;
|
||||||
renderSceneAt(t);
|
renderSceneAt(t);
|
||||||
```
|
```
|
||||||
|
|
||||||
不要让左右服务器各自自由播放:
|
不要让左右输出端各自累计本地 delta:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
// 不推荐
|
// 不推荐
|
||||||
animation += localDeltaTime;
|
animation += localDeltaTime;
|
||||||
```
|
```
|
||||||
|
|
||||||
这样即使某台机器偶尔慢一帧,也会在下一帧追到统一时间,不会越播越偏。
|
## 状态观测
|
||||||
|
|
||||||
## 为什么不推荐单机渲染后网络分发
|
控制台显示:
|
||||||
|
|
||||||
单侧半屏未压缩数据量:
|
- 输出节点数量。
|
||||||
|
- 每个输出节点 FPS、frame time、RTT、clock offset。
|
||||||
|
- 最近命令回显。
|
||||||
|
- 每个 tile 的 `prepared`、`committed`、`late`、`error` 状态。
|
||||||
|
- barrier 放行耗时。
|
||||||
|
|
||||||
|
接口:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
7440 x 3510 x 4 bytes x 60fps ~= 6.3 GB/s
|
GET /api/sync/status
|
||||||
|
GET /healthz
|
||||||
```
|
```
|
||||||
|
|
||||||
左右两侧合计超过 `12 GB/s`。普通网络视频流需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。
|
## 为什么不传视频流
|
||||||
|
|
||||||
|
单侧半屏未压缩数据量约为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
7440 x 3510 x 4 bytes x 60 fps ~= 6.3 GB/s
|
||||||
|
```
|
||||||
|
|
||||||
|
左右两侧合计超过 `12 GB/s`。普通网络视频流还需要编码、传输、解码,会带来延迟、画质损失、文字细线压缩失真,以及新的编码/解码同步问题。
|
||||||
|
|
||||||
|
本系统选择“控制服务发命令,输出端本地渲染”,更适合高分辨率 LED 墙。
|
||||||
|
|
||||||
## 生产验收指标
|
## 生产验收指标
|
||||||
|
|
||||||
建议关注 P95/P99,而不是平均值:
|
建议关注 P95/P99:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
60 FPS:
|
60 FPS:
|
||||||
@@ -182,6 +254,7 @@ animation += localDeltaTime;
|
|||||||
```text
|
```text
|
||||||
WebSocket RTT < 10ms
|
WebSocket RTT < 10ms
|
||||||
clock offset < 5ms
|
clock offset < 5ms
|
||||||
|
left/right 都返回 prepared
|
||||||
left/right 都返回 committed 或 action_committed
|
left/right 都返回 committed 或 action_committed
|
||||||
不得频繁出现 late ACK
|
不得频繁出现 late ACK
|
||||||
```
|
```
|
||||||
@@ -199,12 +272,13 @@ left/right 都返回 committed 或 action_committed
|
|||||||
能保证:
|
能保证:
|
||||||
|
|
||||||
- 两边业务状态一致。
|
- 两边业务状态一致。
|
||||||
- 两边按同一服务端时间点提交。
|
- 两边都准备好后才释放场景提交。
|
||||||
|
- 两边按统一服务端时间点提交。
|
||||||
- 动画长期不漂移。
|
- 动画长期不漂移。
|
||||||
- 命令和性能可观测。
|
- 命令和性能可观测。
|
||||||
|
|
||||||
不能单独保证:
|
不能单独保证:
|
||||||
|
|
||||||
- 两张不同 GPU 每一帧物理扫描完全同相。
|
- 两张不同 GPU 的物理扫描完全同相。
|
||||||
|
|
||||||
如果必须达到广播级帧同步,需要硬件层支持 Genlock / Frame Lock / 专业视频墙控制器。
|
如果必须达到广播级帧同步,需要硬件层支持 Genlock / Frame Lock / 专业视频墙控制器。
|
||||||
|
|||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
# 使用文档
|
||||||
|
|
||||||
|
## 1. 环境要求
|
||||||
|
|
||||||
|
- Python `3.10` 到 `3.12` 推荐。
|
||||||
|
- macOS 本机如果没有 `python` 命令,使用 `python3` 或明确的 Python 3.12 路径。
|
||||||
|
- 浏览器建议使用 Chrome / Chromium / Edge。
|
||||||
|
- 生产输出服务器建议关闭休眠、屏保和自动更新弹窗。
|
||||||
|
|
||||||
|
当前项目依赖见:
|
||||||
|
|
||||||
|
```text
|
||||||
|
requirements.txt
|
||||||
|
pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 首次启动
|
||||||
|
|
||||||
|
在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/tjy/Documents/code/work/dp
|
||||||
|
/Users/tjy/.local/bin/python3.12 -m venv .venv
|
||||||
|
.venv/bin/python -m pip install -r requirements.txt
|
||||||
|
.venv/bin/python -m led_platform.cli serve
|
||||||
|
```
|
||||||
|
|
||||||
|
服务默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要指定端口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m led_platform.cli serve --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 日常启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/tjy/Documents/code/work/dp
|
||||||
|
.venv/bin/python -m led_platform.cli serve
|
||||||
|
```
|
||||||
|
|
||||||
|
查看本机局域网地址:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m led_platform.cli urls
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以直接查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ipconfig getifaddr en0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 常用地址
|
||||||
|
|
||||||
|
本机访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
控制台: http://127.0.0.1:8000/
|
||||||
|
左输出: http://127.0.0.1:8000/output/left
|
||||||
|
右输出: http://127.0.0.1:8000/output/right
|
||||||
|
健康检查: http://127.0.0.1:8000/healthz
|
||||||
|
同步状态: http://127.0.0.1:8000/api/sync/status
|
||||||
|
API 文档: http://127.0.0.1:8000/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
局域网访问时,把 `127.0.0.1` 换成控制服务 IP,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://192.168.0.182:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 控制台使用
|
||||||
|
|
||||||
|
打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://控制服务IP:8000/
|
||||||
|
```
|
||||||
|
|
||||||
|
控制台包含:
|
||||||
|
|
||||||
|
- 画面回显:把 left/right 两个只读预览无缝拼成一块完整画面,桌面布局下位于控制台顶部。
|
||||||
|
- 场景切换:切换 overview、energy、security。
|
||||||
|
- 输出与同步:查看输出端连接数量、FPS、frame time、RTT、clock offset。
|
||||||
|
- 生产命令回显:查看真实 `/output/left` 和 `/output/right` 的 prepared、放行、committed 状态。
|
||||||
|
- 局部动作:翻页、能源模式、告警、暂停/恢复时间轴。
|
||||||
|
|
||||||
|
画面回显使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/output/left?preview=1
|
||||||
|
/output/right?preview=1
|
||||||
|
```
|
||||||
|
|
||||||
|
控制台会把两个预览 iframe 贴在一起,形成 `14880 x 3510` 的完整大屏回显。它是只读预览,不会参与真实输出端 ACK。所以上方画面变化只代表控制台预览已更新;生产命令回显里的等待、放行和完成,代表真实输出端是否已经 ACK。
|
||||||
|
|
||||||
|
## 6. 生产输出端使用
|
||||||
|
|
||||||
|
左 GPU 输出服务器打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://控制服务IP:8000/output/left
|
||||||
|
```
|
||||||
|
|
||||||
|
右 GPU 输出服务器打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://控制服务IP:8000/output/right
|
||||||
|
```
|
||||||
|
|
||||||
|
输出端打开后会:
|
||||||
|
|
||||||
|
1. 请求 `/api/bootstrap/{tile_id}` 获取 tile、场景和当前状态。
|
||||||
|
2. 连接 `/ws/output/{tile_id}`。
|
||||||
|
3. 上报 `hello`,注册为真实输出节点。
|
||||||
|
4. 定时进行时钟同步。
|
||||||
|
5. 定时上报 FPS、frame time、dropped frames。
|
||||||
|
6. 收到场景命令后执行 prepare/barrier/commit。
|
||||||
|
|
||||||
|
## 7. 场景切换流程
|
||||||
|
|
||||||
|
控制台点击“切换”后:
|
||||||
|
|
||||||
|
1. 后端生成新场景状态和 `command_id`。
|
||||||
|
2. 后端广播 `prepare_scene`。
|
||||||
|
3. left/right 真实输出端预加载或预渲染。
|
||||||
|
4. left/right 都回 `prepared`。
|
||||||
|
5. 后端记录 `released_at_ms` 并广播 `commit_scene`。
|
||||||
|
6. left/right 等到 `apply_at_ms` 同步显示。
|
||||||
|
7. left/right 回 `committed` 或 `late`。
|
||||||
|
|
||||||
|
在控制台“命令回显”里可以看到每个阶段。
|
||||||
|
|
||||||
|
## 8. 接入真实超分渲染
|
||||||
|
|
||||||
|
输出端预留了 hook:
|
||||||
|
|
||||||
|
```js
|
||||||
|
window.ledPlatformPrepareScene = async ({ commandId, tile, state, scene }) => {
|
||||||
|
// 1. 根据 scene/state 准备真实资源
|
||||||
|
// 2. 执行超分或离屏渲染
|
||||||
|
// 3. 确认下一次 commit 可以无卡顿显示
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
只要这个 Promise 不 resolve,输出端就不会发送 `prepared`。后端也就不会释放 `commit_scene`。
|
||||||
|
|
||||||
|
如果超分失败,输出端会回:
|
||||||
|
|
||||||
|
```text
|
||||||
|
status=error
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 配置文件
|
||||||
|
|
||||||
|
场景配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/scenes.json
|
||||||
|
```
|
||||||
|
|
||||||
|
字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id 场景 ID
|
||||||
|
name 控制台显示名
|
||||||
|
url 场景 URL,目前示例都使用 /wall-app
|
||||||
|
view 输出端内部视图名
|
||||||
|
description 描述
|
||||||
|
preload 准备阶段预加载资源
|
||||||
|
```
|
||||||
|
|
||||||
|
Tile 配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/tiles.json
|
||||||
|
```
|
||||||
|
|
||||||
|
字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id left / right
|
||||||
|
x, y 在完整逻辑大屏中的起点
|
||||||
|
width, height tile 尺寸
|
||||||
|
wall_width 完整大屏宽度
|
||||||
|
wall_height 完整大屏高度
|
||||||
|
desktop_width 单台 GPU 服务器桌面宽度
|
||||||
|
desktop_height 单台 GPU 服务器桌面高度
|
||||||
|
physical_outputs 4 路 4K 输出映射
|
||||||
|
```
|
||||||
|
|
||||||
|
环境变量前缀:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LED_
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LED_APP_PORT=9000 .venv/bin/python -m led_platform.cli serve
|
||||||
|
LED_SWITCH_PREPARE_DELAY_MS=3000 .venv/bin/python -m led_platform.cli serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m pytest -q
|
||||||
|
.venv/bin/python -m compileall led_platform tests
|
||||||
|
node --check led_platform/web/static/output/main.js
|
||||||
|
node --check led_platform/web/static/admin/main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
同步状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/api/sync/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. 常见问题
|
||||||
|
|
||||||
|
### 控制台看不到画面回显
|
||||||
|
|
||||||
|
先强制刷新控制台页面。画面回显在控制台顶部,标题为“画面回显”。
|
||||||
|
|
||||||
|
如果仍然看不到,检查:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://控制服务IP:8000/output/left?preview=1
|
||||||
|
http://控制服务IP:8000/output/right?preview=1
|
||||||
|
```
|
||||||
|
|
||||||
|
这两个地址应能单独打开预览画面。
|
||||||
|
|
||||||
|
### 命令回显一直显示等待
|
||||||
|
|
||||||
|
检查真实输出端是否打开的是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/output/left
|
||||||
|
/output/right
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把生产输出端打开成 `?preview=1`,预览模式不会 ACK。
|
||||||
|
|
||||||
|
### 切换场景不放行
|
||||||
|
|
||||||
|
说明至少一个真实输出端没有回 `prepared`。查看:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/sync/status
|
||||||
|
```
|
||||||
|
|
||||||
|
重点看 `nodes` 是否有 left/right,`commands[].acks` 是否有两个 tile。
|
||||||
|
|
||||||
|
### 局域网机器打不开
|
||||||
|
|
||||||
|
检查:
|
||||||
|
|
||||||
|
- 服务是否监听 `0.0.0.0:8000`。
|
||||||
|
- 控制服务机器防火墙是否允许 8000。
|
||||||
|
- 输出服务器是否和控制服务在同一网络。
|
||||||
|
- URL 是否使用控制服务的局域网 IP,而不是 `127.0.0.1`。
|
||||||
|
|
||||||
|
### 控制台预览和生产输出不同步
|
||||||
|
|
||||||
|
控制台预览是观察用途,运行在控制台浏览器中,不参与 ACK,也不代表生产输出端性能。最终验收应以真实 `/output/left` 和 `/output/right` 为准。
|
||||||
@@ -13,6 +13,11 @@ STATIC_DIR = WEB_DIR / "static"
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
HTML_HEADERS = {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Clear-Site-Data": '"cache"',
|
||||||
|
}
|
||||||
|
|
||||||
ALLOWED_ACTIONS = {
|
ALLOWED_ACTIONS = {
|
||||||
"page.next",
|
"page.next",
|
||||||
"page.prev",
|
"page.prev",
|
||||||
@@ -20,12 +25,14 @@ ALLOWED_ACTIONS = {
|
|||||||
"security.alert",
|
"security.alert",
|
||||||
"timeline.pause",
|
"timeline.pause",
|
||||||
"timeline.resume",
|
"timeline.resume",
|
||||||
|
"motion.mode",
|
||||||
|
"scenario.focus",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", include_in_schema=False)
|
@router.get("/", include_in_schema=False)
|
||||||
async def admin_index() -> FileResponse:
|
async def admin_index() -> FileResponse:
|
||||||
return FileResponse(STATIC_DIR / "admin" / "index.html")
|
return FileResponse(STATIC_DIR / "admin" / "index.html", headers=HTML_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/output/{tile_id}", include_in_schema=False)
|
@router.get("/output/{tile_id}", include_in_schema=False)
|
||||||
@@ -34,12 +41,12 @@ async def output_index(tile_id: str) -> FileResponse:
|
|||||||
tiles.get(tile_id)
|
tiles.get(tile_id)
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc
|
raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc
|
||||||
return FileResponse(STATIC_DIR / "output" / "index.html")
|
return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wall-app", include_in_schema=False)
|
@router.get("/wall-app", include_in_schema=False)
|
||||||
async def wall_app() -> FileResponse:
|
async def wall_app() -> FileResponse:
|
||||||
return FileResponse(STATIC_DIR / "output" / "index.html")
|
return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/favicon.ico", include_in_schema=False)
|
@router.get("/favicon.ico", include_in_schema=False)
|
||||||
@@ -106,7 +113,6 @@ async def switch_scene(scene_id: str) -> dict:
|
|||||||
)
|
)
|
||||||
sync.register_command(commit, payload)
|
sync.register_command(commit, payload)
|
||||||
await hub.broadcast(prepare, "admin", "outputs")
|
await hub.broadcast(prepare, "admin", "outputs")
|
||||||
await hub.broadcast(commit, "admin", "outputs")
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -149,4 +155,3 @@ async def sync_status() -> dict:
|
|||||||
async def telemetry(sample: PerformanceSample) -> dict:
|
async def telemetry(sample: PerformanceSample) -> dict:
|
||||||
sync.telemetry(sample)
|
sync.telemetry(sample)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,19 @@ async def output_ws(websocket: WebSocket, tile_id: str) -> None:
|
|||||||
node_id = str(raw.get("node_id") or node_id or "")
|
node_id = str(raw.get("node_id") or node_id or "")
|
||||||
sync.clock_quality(node_id, raw.get("clock_offset_ms"), raw.get("rtt_ms"))
|
sync.clock_quality(node_id, raw.get("clock_offset_ms"), raw.get("rtt_ms"))
|
||||||
record = sync.ack(raw)
|
record = sync.ack(raw)
|
||||||
|
release_record = None
|
||||||
|
if record and raw.get("status") == "prepared":
|
||||||
|
release_record = sync.release_if_prepared(record.command_id)
|
||||||
|
if release_record:
|
||||||
|
await hub.broadcast(
|
||||||
|
CommandEnvelope(
|
||||||
|
type=CommandType.COMMIT_SCENE,
|
||||||
|
command_id=release_record.command_id,
|
||||||
|
payload=release_record.payload,
|
||||||
|
),
|
||||||
|
"admin",
|
||||||
|
"outputs",
|
||||||
|
)
|
||||||
await hub.broadcast(
|
await hub.broadcast(
|
||||||
CommandEnvelope(
|
CommandEnvelope(
|
||||||
type=CommandType.ACK,
|
type=CommandType.ACK,
|
||||||
|
|||||||
@@ -128,9 +128,19 @@ class CommandRecord(BaseModel):
|
|||||||
apply_at_ms: int
|
apply_at_ms: int
|
||||||
created_at_ms: int = Field(default_factory=epoch_ms)
|
created_at_ms: int = Field(default_factory=epoch_ms)
|
||||||
expires_at_ms: int
|
expires_at_ms: int
|
||||||
|
released_at_ms: int | None = None
|
||||||
payload: dict[str, Any] = Field(default_factory=dict)
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
acks: list[dict[str, Any]] = Field(default_factory=list)
|
acks: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prepared(self) -> bool:
|
||||||
|
prepared_tiles = {
|
||||||
|
ack.get("tile_id")
|
||||||
|
for ack in self.acks
|
||||||
|
if ack.get("status") in {AckStatus.PREPARED, AckStatus.COMMITTED}
|
||||||
|
}
|
||||||
|
return set(self.target_tiles).issubset({str(tile) for tile in prepared_tiles if tile})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def complete(self) -> bool:
|
def complete(self) -> bool:
|
||||||
completed = {
|
completed = {
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
from led_platform.api.http import STATIC_DIR, router as http_router
|
from led_platform.api.http import STATIC_DIR, router as http_router
|
||||||
from led_platform.api.ws import router as ws_router
|
from led_platform.api.ws import router as ws_router
|
||||||
from led_platform.core.config import get_settings
|
from led_platform.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class NoCacheStaticFiles(StaticFiles):
|
||||||
|
async def get_response(self, path: str, scope: dict) -> Response:
|
||||||
|
response = await super().get_response(path, scope)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LED Wall Projection Platform",
|
title="LED Wall Projection Platform",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
description="Tile-aware dual GPU-server LED wall control and synchronization platform.",
|
description="Tile-aware dual GPU-server LED wall control and synchronization platform.",
|
||||||
)
|
)
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||||
app.include_router(http_router)
|
app.include_router(http_router)
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ class SyncCoordinator:
|
|||||||
record.acks.append(ack_payload)
|
record.acks.append(ack_payload)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
def release_if_prepared(self, command_id: str) -> CommandRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
self._prune_locked()
|
||||||
|
record = self._commands.get(command_id)
|
||||||
|
if not record or record.released_at_ms is not None or not record.prepared:
|
||||||
|
return None
|
||||||
|
record.released_at_ms = epoch_ms()
|
||||||
|
return record
|
||||||
|
|
||||||
def clock_quality(
|
def clock_quality(
|
||||||
self,
|
self,
|
||||||
node_id: str | None,
|
node_id: str | None,
|
||||||
|
|||||||
@@ -7,25 +7,95 @@
|
|||||||
<link rel="stylesheet" href="/static/admin/styles.css" />
|
<link rel="stylesheet" href="/static/admin/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="shell">
|
<main id="adminApp" class="shell" v-cloak>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div>
|
<div>
|
||||||
<h1>LED 大屏投放控制台</h1>
|
<h1>LED 大屏投放控制台</h1>
|
||||||
<p id="stateText">正在连接控制服务...</p>
|
<p>{{ statusText }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="connection">
|
<div class="connection">
|
||||||
<span id="wsDot" class="dot"></span>
|
<span class="dot" :class="{ ok: wsConnected }"></span>
|
||||||
<span id="wsText">WebSocket</span>
|
<span>{{ wsText }}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<section class="panel scenes">
|
||||||
<div class="panelHead">
|
<div class="panelHead">
|
||||||
<h2>场景切换</h2>
|
<h2>场景切换</h2>
|
||||||
<button id="refreshBtn">刷新</button>
|
<button type="button" @click="loadScenes">刷新</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -38,30 +108,54 @@
|
|||||||
<a href="/api/sync/status" target="_blank">同步状态</a>
|
<a href="/api/sync/status" target="_blank">同步状态</a>
|
||||||
<a href="/docs" target="_blank">API 文档</a>
|
<a href="/docs" target="_blank">API 文档</a>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panelHead">
|
<div class="panelHead compact">
|
||||||
<h2>局部动作</h2>
|
<div>
|
||||||
|
<h2>生产命令回显</h2>
|
||||||
|
<p class="panelHint">真实输出端 ACK,不代表上方预览</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actionGrid">
|
<div class="commandEcho">
|
||||||
<button data-action="page.prev">上一页</button>
|
<div v-if="recentCommands.length === 0" class="emptyEcho">暂无命令回显</div>
|
||||||
<button data-action="page.next">下一页</button>
|
<article v-for="command in recentCommands" :key="command.command_id" class="echoCard">
|
||||||
<button data-action="energy.mode" data-args='{"mode":"削峰"}'>削峰模式</button>
|
<div class="echoHead">
|
||||||
<button data-action="energy.mode" data-args='{"mode":"保供"}'>保供模式</button>
|
<div>
|
||||||
<button data-action="security.alert" data-args='{"text":"门禁异常联动"}'>新增告警</button>
|
<h4>{{ commandTitle(command) }}</h4>
|
||||||
<button data-action="timeline.pause">暂停时间轴</button>
|
<p>{{ command.command_id.slice(0, 12) }} | 计划 {{ formatClock(command.apply_at_ms) }} | {{ releaseText(command) }}</p>
|
||||||
<button data-action="timeline.resume">恢复时间轴</button>
|
<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>
|
</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>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/static/vendor/vue.global.prod.js"></script>
|
||||||
<script src="/static/admin/main.js"></script>
|
<script src="/static/admin/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,155 +1,250 @@
|
|||||||
const sceneList = document.querySelector("#sceneList");
|
const { createApp } = Vue;
|
||||||
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 = [];
|
createApp({
|
||||||
let state = null;
|
data() {
|
||||||
let syncSnapshot = null;
|
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() {
|
computed: {
|
||||||
const res = await fetch("/api/scenes");
|
nodes() {
|
||||||
const data = await res.json();
|
return this.syncSnapshot?.nodes || [];
|
||||||
scenes = data.scenes;
|
},
|
||||||
state = data.state;
|
targetTiles() {
|
||||||
renderScenes();
|
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() {
|
async mounted() {
|
||||||
const res = await fetch("/api/sync/status");
|
await Promise.all([this.loadScenes(), this.loadSyncStatus()]);
|
||||||
syncSnapshot = await res.json();
|
this.connectWs();
|
||||||
renderSyncStatus();
|
setInterval(() => this.loadSyncStatus(), 2500);
|
||||||
}
|
},
|
||||||
|
|
||||||
function renderScenes() {
|
methods: {
|
||||||
if (!state) return;
|
async loadScenes() {
|
||||||
stateText.textContent = `当前场景:${state.active_scene_id},版本 ${state.version}`;
|
const res = await fetch("/api/scenes");
|
||||||
sceneList.innerHTML = "";
|
const data = await res.json();
|
||||||
for (const scene of scenes) {
|
this.scenes = data.scenes;
|
||||||
const item = document.createElement("article");
|
this.state = data.state;
|
||||||
item.className = `scene ${scene.id === state.active_scene_id ? "active" : ""}`;
|
this.statusText = `当前场景:${this.state.active_scene_id},版本 ${this.state.version}`;
|
||||||
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() {
|
async loadSyncStatus() {
|
||||||
if (!syncSnapshot) return;
|
const res = await fetch("/api/sync/status");
|
||||||
const nodes = syncSnapshot.nodes || [];
|
this.syncSnapshot = await res.json();
|
||||||
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>`);
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
async switchScene(sceneId) {
|
||||||
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
|
this.busySceneId = sceneId;
|
||||||
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
|
try {
|
||||||
const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`;
|
const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" });
|
||||||
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
|
if (!res.ok) {
|
||||||
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>`);
|
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) {
|
async postAction(action, args = {}, target = "wall") {
|
||||||
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
|
const res = await fetch("/api/actions", {
|
||||||
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
|
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) {
|
connectWs() {
|
||||||
const res = await fetch(`/api/scenes/${sceneId}/switch`, { method: "POST" });
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
if (!res.ok) {
|
const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
|
||||||
alert(await res.text());
|
ws.addEventListener("open", () => {
|
||||||
return;
|
this.wsConnected = true;
|
||||||
}
|
this.wsText = "WebSocket 已连接";
|
||||||
const data = await res.json();
|
});
|
||||||
state = data.state;
|
ws.addEventListener("close", () => {
|
||||||
const time = new Date(data.apply_at_ms).toLocaleTimeString("zh-CN", { hour12: false });
|
this.wsConnected = false;
|
||||||
stateText.textContent = `计划切换:${state.active_scene_id},提交时间 ${time}`;
|
this.wsText = "WebSocket 重连中";
|
||||||
renderScenes();
|
setTimeout(() => this.connectWs(), 1200);
|
||||||
loadSyncStatus();
|
});
|
||||||
}
|
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") {
|
formatClock(ms) {
|
||||||
const res = await fetch("/api/actions", {
|
if (!Number.isFinite(Number(ms))) return "--";
|
||||||
method: "POST",
|
return new Date(Number(ms)).toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
headers: { "content-type": "application/json" },
|
},
|
||||||
body: JSON.stringify({ target, action, args }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
alert(await res.text());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadSyncStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCustomAction() {
|
formatDelta(ms) {
|
||||||
const raw = actionInput.value.trim();
|
if (!Number.isFinite(Number(ms))) return "--";
|
||||||
if (!raw) return;
|
const value = Math.round(Number(ms));
|
||||||
let payload;
|
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
|
||||||
try {
|
},
|
||||||
payload = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
payload = { action: raw, args: {} };
|
|
||||||
}
|
|
||||||
await postAction(payload.action, payload.args || {}, payload.target || "wall");
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWs() {
|
nodeMetric(node) {
|
||||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
|
||||||
const ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
|
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
|
||||||
ws.addEventListener("open", () => {
|
const rtt = node.rtt_ms == null ? "--" : `${Math.round(node.rtt_ms)}ms`;
|
||||||
wsDot.classList.add("ok");
|
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
|
||||||
wsText.textContent = "WebSocket 已连接";
|
return `${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}`;
|
||||||
});
|
},
|
||||||
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);
|
commandTitle(command) {
|
||||||
runActionBtn.addEventListener("click", runCustomAction);
|
if (command.command_type === "commit_scene") {
|
||||||
document.querySelectorAll("[data-action]").forEach((button) => {
|
return `场景 ${command.payload?.state?.active_scene_id || command.command_id.slice(0, 8)}`;
|
||||||
button.addEventListener("click", () => {
|
}
|
||||||
const args = button.dataset.args ? JSON.parse(button.dataset.args) : {};
|
if (command.command_type === "component_action") {
|
||||||
postAction(button.dataset.action, args);
|
return `动作 ${command.payload?.action || command.command_id.slice(0, 8)}`;
|
||||||
});
|
}
|
||||||
});
|
return `${command.command_type} ${command.command_id.slice(0, 8)}`;
|
||||||
|
},
|
||||||
|
|
||||||
loadScenes();
|
releaseText(command) {
|
||||||
loadSyncStatus();
|
return command.released_at_ms
|
||||||
connectWs();
|
? `放行 ${this.formatDelta(command.released_at_ms - command.created_at_ms)}`
|
||||||
setInterval(loadSyncStatus, 2500);
|
: "等待真实输出端渲染";
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[v-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -24,9 +28,9 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
width: min(1280px, calc(100vw - 40px));
|
width: min(1760px, calc(100vw - 24px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 28px 0 40px;
|
padding: 16px 0 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
@@ -38,8 +42,12 @@ textarea {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelHead.compact {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
margin-bottom: 22px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -50,7 +58,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,27 +86,42 @@ h1 {
|
|||||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.consoleLayout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(360px, 0.8fr);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewPanel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.055);
|
background: rgba(255, 255, 255, 0.055);
|
||||||
padding: 18px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenes {
|
.panelMeta {
|
||||||
grid-row: span 2;
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelHint {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
.linkGrid a {
|
.linkGrid a {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
@@ -115,6 +138,58 @@ button:hover,
|
|||||||
border-color: rgba(20, 184, 166, 0.8);
|
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 {
|
.primary {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -127,7 +202,8 @@ button:hover,
|
|||||||
.sceneList,
|
.sceneList,
|
||||||
.syncStatus,
|
.syncStatus,
|
||||||
.linkGrid,
|
.linkGrid,
|
||||||
.actionGrid {
|
.actionGrid,
|
||||||
|
.commandEcho {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -135,19 +211,19 @@ button:hover,
|
|||||||
.sceneList,
|
.sceneList,
|
||||||
.syncStatus,
|
.syncStatus,
|
||||||
.linkGrid {
|
.linkGrid {
|
||||||
margin-top: 14px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.linkGrid {
|
.linkGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene {
|
.scene {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px;
|
padding: 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(255, 255, 255, 0.045);
|
background: rgba(255, 255, 255, 0.045);
|
||||||
@@ -159,13 +235,13 @@ button:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene h3 {
|
.scene h3 {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene p {
|
.scene p {
|
||||||
color: #aeb7c2;
|
color: #aeb7c2;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,15 +257,105 @@ button:hover,
|
|||||||
color: #f8fafc;
|
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 {
|
.actionGrid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
margin-top: 14px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.customAction {
|
.customAction {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 14px;
|
margin-top: 10px;
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,14 +369,44 @@ textarea {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1240px) {
|
||||||
|
.consoleLayout {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewPanel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.grid,
|
.shell {
|
||||||
|
width: min(100vw - 12px, 1760px);
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consoleLayout,
|
||||||
.topbar {
|
.topbar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenes {
|
.previewTile header {
|
||||||
grid-row: auto;
|
min-height: 34px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: portrait) and (max-width: 680px) {
|
||||||
|
.previewGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,61 +7,78 @@
|
|||||||
<link rel="stylesheet" href="/static/output/styles.css" />
|
<link rel="stylesheet" href="/static/output/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="viewport">
|
<main id="outputApp" class="viewport" v-cloak>
|
||||||
<section id="wall" class="wall">
|
<section
|
||||||
<canvas id="motionCanvas" class="motion-canvas"></canvas>
|
ref="wall"
|
||||||
|
id="wall"
|
||||||
|
class="wall"
|
||||||
|
:class="[`theme-${activeView}`, `motion-${motionMode}`, `focus-${focusTarget}`, { pulse: pulseActive }]"
|
||||||
|
>
|
||||||
|
<canvas ref="motionCanvas" id="motionCanvas" class="motion-canvas"></canvas>
|
||||||
<div class="grid-layer"></div>
|
<div class="grid-layer"></div>
|
||||||
|
<div class="scan-layer"></div>
|
||||||
|
|
||||||
<header class="screen-header">
|
<header class="screen-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tile-aware timeline rendering</p>
|
<p class="eyebrow">Synchronized LED Wall Platform</p>
|
||||||
<h1 id="sceneTitle">LED 大屏投放平台</h1>
|
<h1>{{ sceneTitle }}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-metrics">
|
<div class="header-metrics">
|
||||||
<strong id="clockText">--:--:--</strong>
|
<strong>{{ clockText }}</strong>
|
||||||
<span id="versionText">v0</span>
|
<span>v{{ state?.version || 0 }}</span>
|
||||||
|
<em>{{ tile?.id || "--" }} / {{ isPreview ? "PREVIEW" : "OUTPUT" }}</em>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="scene active" data-view="overview">
|
<section
|
||||||
|
v-for="scene in sceneDefinitions"
|
||||||
|
:key="scene.id"
|
||||||
|
class="scene"
|
||||||
|
:class="{ active: activeView === scene.id }"
|
||||||
|
:data-view="scene.id"
|
||||||
|
>
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<h2>双 GPU 本地渲染,统一时间轴同步</h2>
|
<span>{{ scene.kicker }}</span>
|
||||||
<p>左右输出端各自渲染半屏,但场景、动画、地图相机、视频时间由同一个服务端时间轴驱动。</p>
|
<h2>{{ scene.headline }}</h2>
|
||||||
|
<p>{{ scene.copy }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="overviewCards" class="card-grid"></div>
|
|
||||||
<div id="timelineBars" class="timeline-bars"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="scene" data-view="energy">
|
<div class="metric-grid">
|
||||||
<div class="hero-copy">
|
<article v-for="metric in scene.metrics" :key="metric.label" class="metric-card">
|
||||||
<h2>能源驾驶舱</h2>
|
<span>{{ metric.label }}</span>
|
||||||
<p>适合 WebGL、Three.js、地图和视频组件。生产接入时使用同一 camera state 和 serverTime 渲染。</p>
|
<strong>{{ metric.value }}</strong>
|
||||||
|
<small>{{ metric.trend }}</small>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-map">
|
|
||||||
<div class="flow-line line-a"></div>
|
|
||||||
<div class="flow-line line-b"></div>
|
|
||||||
<div class="flow-node node-a">园区供电</div>
|
|
||||||
<div class="flow-node node-b">储能系统</div>
|
|
||||||
<div class="flow-node node-c">业务负载</div>
|
|
||||||
</div>
|
|
||||||
<div class="side-stats">
|
|
||||||
<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="visual-stage">
|
||||||
<div class="hero-copy">
|
<div class="route-field">
|
||||||
<h2>安防态势</h2>
|
<i v-for="line in scene.lines" :key="line" :class="['route-line', line]"></i>
|
||||||
<p>视频墙、地图联动和告警卡片通过结构化动作同步触发,左右节点只按计划时间提交状态。</p>
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="node in scene.nodes"
|
||||||
|
:key="node.id"
|
||||||
|
class="stage-node"
|
||||||
|
:class="node.tone"
|
||||||
|
:style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||||
|
>
|
||||||
|
<span>{{ node.label }}</span>
|
||||||
|
<strong>{{ node.value }}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="cameraGrid" class="camera-grid"></div>
|
|
||||||
<div id="alertRail" class="alert-rail"></div>
|
<aside class="event-rail">
|
||||||
|
<article v-for="event in scene.events" :key="event.title" class="event-card">
|
||||||
|
<span>{{ event.time }}</span>
|
||||||
|
<strong>{{ event.title }}</strong>
|
||||||
|
<small>{{ event.detail }}</small>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<aside id="hud" class="hud"></aside>
|
<aside ref="hud" id="hud" class="hud">{{ hudText }}</aside>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/static/vendor/vue.global.prod.js"></script>
|
||||||
<script src="/static/output/main.js"></script>
|
<script src="/static/output/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,332 +1,583 @@
|
|||||||
const APP_VERSION = "3.0.0";
|
const { createApp } = Vue;
|
||||||
|
|
||||||
const wall = document.querySelector("#wall");
|
const APP_VERSION = "4.0.0-vue3";
|
||||||
const canvas = document.querySelector("#motionCanvas");
|
|
||||||
const ctx = canvas.getContext("2d", { alpha: true });
|
|
||||||
const hud = document.querySelector("#hud");
|
|
||||||
const sceneTitle = document.querySelector("#sceneTitle");
|
|
||||||
const versionText = document.querySelector("#versionText");
|
|
||||||
const clockText = document.querySelector("#clockText");
|
|
||||||
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
||||||
|
const isPreview = new URLSearchParams(location.search).get("preview") === "1";
|
||||||
|
const nodeStorageKey = `led-platform-node-id-${tileId}`;
|
||||||
|
|
||||||
const sceneTitles = {
|
function createSceneDefinitions() {
|
||||||
overview: "LED 大屏投放平台",
|
return [
|
||||||
energy: "能源驾驶舱",
|
{
|
||||||
security: "安防态势",
|
id: "overview",
|
||||||
};
|
title: "LED 大屏投放平台",
|
||||||
|
kicker: "GLOBAL WALL OVERVIEW",
|
||||||
let tile = null;
|
headline: "双 GPU 本地渲染,统一时间轴同步",
|
||||||
let state = null;
|
copy: "左右输出端各自渲染半屏,场景、动作、地图相机和动画由同一个服务端时间轴驱动。",
|
||||||
let ws = null;
|
metrics: [
|
||||||
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
{ label: "逻辑宽度", value: "14880", trend: "px" },
|
||||||
let serverOffsetFromPerfMs = Date.now() - performance.now();
|
{ label: "逻辑高度", value: "3510", trend: "px" },
|
||||||
let bestRttMs = Number.POSITIVE_INFINITY;
|
{ label: "输出节点", value: "2", trend: "GPU servers" },
|
||||||
let clockSamples = [];
|
{ label: "帧同步", value: "ACK", trend: "barrier" },
|
||||||
let scheduledJobs = new Map();
|
],
|
||||||
let frameCount = 0;
|
lines: ["line-a", "line-b", "line-c"],
|
||||||
let fps = 0;
|
nodes: [
|
||||||
let frameTimeMs = 0;
|
{ id: "control", label: "控制服务", value: "ACTIVE", x: 720, y: 620, tone: "cyan" },
|
||||||
let droppedFrames = 0;
|
{ id: "left", label: "左侧 tile", value: "7440px", x: 3400, y: 1100, tone: "blue" },
|
||||||
let lastFrameAt = performance.now();
|
{ id: "right", label: "右侧 tile", value: "7440px", x: 7100, y: 880, tone: "orange" },
|
||||||
let lastFpsAt = performance.now();
|
{ id: "wall", label: "LED 控制器", value: "8 INPUT", x: 10300, y: 1260, tone: "cyan" },
|
||||||
let pageIndex = 0;
|
],
|
||||||
let paused = false;
|
events: [
|
||||||
|
{ time: "T+00", title: "场景统一", detail: "prepare / commit 协议" },
|
||||||
if (!nodeId) {
|
{ time: "T+02", title: "时间校准", detail: "RTT 采样与 serverNow" },
|
||||||
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
|
{ time: "T+04", title: "本地渲染", detail: "两端分别绘制 tile" },
|
||||||
nodeId = `${tileId}-${randomId}`;
|
],
|
||||||
sessionStorage.setItem("led-platform-node-id", nodeId);
|
},
|
||||||
|
{
|
||||||
|
id: "energy",
|
||||||
|
title: "能源驾驶舱",
|
||||||
|
kicker: "ENERGY GRID COMMAND",
|
||||||
|
headline: "源网荷储一体化调度",
|
||||||
|
copy: "实时监控园区供电、储能、负荷趋势和削峰策略,支持模式切换与调度策略下发。",
|
||||||
|
metrics: [
|
||||||
|
{ label: "实时功率", value: "8.42", trend: "MW" },
|
||||||
|
{ label: "储能 SOC", value: "72", trend: "%" },
|
||||||
|
{ label: "负载趋势", value: "+3.8", trend: "%" },
|
||||||
|
{ label: "调度策略", value: "均衡", trend: "auto" },
|
||||||
|
],
|
||||||
|
lines: ["line-a", "line-d", "line-e"],
|
||||||
|
nodes: [
|
||||||
|
{ id: "grid", label: "园区供电", value: "稳定", x: 850, y: 760, tone: "cyan" },
|
||||||
|
{ id: "storage", label: "储能系统", value: "72%", x: 3900, y: 520, tone: "blue" },
|
||||||
|
{ id: "load", label: "业务负载", value: "8.42MW", x: 7600, y: 980, tone: "orange" },
|
||||||
|
{ id: "strategy", label: "调度策略", value: "均衡", x: 10800, y: 620, tone: "cyan" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ time: "09:12", title: "负载预测上调", detail: "未来 15 分钟 +3.8%" },
|
||||||
|
{ time: "09:18", title: "储能响应", detail: "2 组电池进入待命" },
|
||||||
|
{ time: "09:23", title: "策略校验", detail: "削峰阈值未触发" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "security",
|
||||||
|
title: "安防态势",
|
||||||
|
kicker: "SECURITY SITUATION",
|
||||||
|
headline: "视频墙、告警与区域联动",
|
||||||
|
copy: "多路视频、门禁、消防与巡检事件按统一时间轴联动,支持局部告警动作同步触发。",
|
||||||
|
metrics: [
|
||||||
|
{ label: "在线摄像", value: "128", trend: "channels" },
|
||||||
|
{ label: "风险指数", value: "17", trend: "low" },
|
||||||
|
{ label: "巡检任务", value: "42", trend: "running" },
|
||||||
|
{ label: "联动告警", value: "4", trend: "active" },
|
||||||
|
],
|
||||||
|
lines: ["line-b", "line-c", "line-f"],
|
||||||
|
nodes: [
|
||||||
|
{ id: "north", label: "北区通道", value: "关注", x: 980, y: 880, tone: "orange" },
|
||||||
|
{ id: "center", label: "中心机房", value: "正常", x: 4200, y: 530, tone: "cyan" },
|
||||||
|
{ id: "gate", label: "门禁系统", value: "1 异常", x: 7550, y: 1200, tone: "orange" },
|
||||||
|
{ id: "fire", label: "消防联动", value: "待命", x: 10900, y: 760, tone: "blue" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ time: "10:31", title: "门禁异常联动", detail: "B2 机房侧门" },
|
||||||
|
{ time: "10:34", title: "人员聚集", detail: "北侧通道" },
|
||||||
|
{ time: "10:40", title: "巡检完成", detail: "A 区 12 个点位" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "command",
|
||||||
|
title: "指挥调度",
|
||||||
|
kicker: "MISSION CONTROL",
|
||||||
|
headline: "跨区域任务派发与执行闭环",
|
||||||
|
copy: "事件、队伍、资源和任务状态聚合在同一个指挥视图,适合现场大屏调度与演练展示。",
|
||||||
|
metrics: [
|
||||||
|
{ label: "事件队列", value: "23", trend: "items" },
|
||||||
|
{ label: "响应队伍", value: "11", trend: "teams" },
|
||||||
|
{ label: "闭环率", value: "96", trend: "%" },
|
||||||
|
{ label: "平均响应", value: "4.6", trend: "min" },
|
||||||
|
],
|
||||||
|
lines: ["line-a", "line-e", "line-f"],
|
||||||
|
nodes: [
|
||||||
|
{ id: "hq", label: "指挥中心", value: "ONLINE", x: 920, y: 620, tone: "cyan" },
|
||||||
|
{ id: "team", label: "响应队伍", value: "11", x: 4100, y: 1080, tone: "blue" },
|
||||||
|
{ id: "resource", label: "资源池", value: "82%", x: 7500, y: 620, tone: "orange" },
|
||||||
|
{ id: "task", label: "任务闭环", value: "96%", x: 10800, y: 1050, tone: "cyan" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ time: "11:05", title: "任务派发", detail: "3 支队伍已接收" },
|
||||||
|
{ time: "11:08", title: "资源锁定", detail: "移动电源与无人机" },
|
||||||
|
{ time: "11:16", title: "阶段回执", detail: "现场反馈正常" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "transport",
|
||||||
|
title: "交通运行",
|
||||||
|
kicker: "TRANSPORT NETWORK",
|
||||||
|
headline: "路网运行与运力调度",
|
||||||
|
copy: "车流、站点、路段拥堵与运力资源在大屏上联动呈现,支持重点区域快速聚焦。",
|
||||||
|
metrics: [
|
||||||
|
{ label: "车流指数", value: "68", trend: "stable" },
|
||||||
|
{ label: "拥堵路段", value: "7", trend: "segments" },
|
||||||
|
{ label: "准点率", value: "91", trend: "%" },
|
||||||
|
{ label: "可用运力", value: "84", trend: "%" },
|
||||||
|
],
|
||||||
|
lines: ["line-b", "line-d", "line-f"],
|
||||||
|
nodes: [
|
||||||
|
{ id: "hub", label: "枢纽站", value: "91%", x: 760, y: 980, tone: "cyan" },
|
||||||
|
{ id: "road", label: "主干路", value: "68", x: 3900, y: 540, tone: "orange" },
|
||||||
|
{ id: "fleet", label: "运力池", value: "84%", x: 7600, y: 1150, tone: "blue" },
|
||||||
|
{ id: "signal", label: "信号优化", value: "运行", x: 10900, y: 700, tone: "cyan" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ time: "12:20", title: "车流高峰", detail: "东向主线压力上升" },
|
||||||
|
{ time: "12:26", title: "信号配时", detail: "绿波方案已下发" },
|
||||||
|
{ time: "12:31", title: "运力补偿", detail: "3 条线路加车" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dataflow",
|
||||||
|
title: "数据中枢",
|
||||||
|
kicker: "AI DATA FABRIC",
|
||||||
|
headline: "数据流入、计算与智能分析",
|
||||||
|
copy: "多源数据经过实时管道进入计算集群,AI 推理结果以指标、事件和预测态势输出。",
|
||||||
|
metrics: [
|
||||||
|
{ label: "接入数据", value: "2.8", trend: "TB/h" },
|
||||||
|
{ label: "计算任务", value: "418", trend: "jobs" },
|
||||||
|
{ label: "AI 推理", value: "36", trend: "models" },
|
||||||
|
{ label: "延迟 P95", value: "42", trend: "ms" },
|
||||||
|
],
|
||||||
|
lines: ["line-a", "line-c", "line-d"],
|
||||||
|
nodes: [
|
||||||
|
{ id: "ingest", label: "数据接入", value: "2.8TB/h", x: 780, y: 650, tone: "cyan" },
|
||||||
|
{ id: "compute", label: "计算集群", value: "418", x: 4100, y: 1050, tone: "blue" },
|
||||||
|
{ id: "ai", label: "AI 推理", value: "36", x: 7700, y: 580, tone: "orange" },
|
||||||
|
{ id: "output", label: "指标输出", value: "42ms", x: 10800, y: 1160, tone: "cyan" },
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
{ time: "13:02", title: "数据流入峰值", detail: "北区视频数据增加" },
|
||||||
|
{ time: "13:07", title: "模型推理完成", detail: "风险识别批次 19" },
|
||||||
|
{ time: "13:10", title: "指标写入", detail: "P95 延迟 42ms" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverNowMs() {
|
createApp({
|
||||||
return performance.now() + serverOffsetFromPerfMs;
|
data() {
|
||||||
}
|
return {
|
||||||
|
tile: null,
|
||||||
|
state: null,
|
||||||
|
activeView: "overview",
|
||||||
|
sceneDefinitions: createSceneDefinitions(),
|
||||||
|
ws: null,
|
||||||
|
nodeId: sessionStorage.getItem(nodeStorageKey),
|
||||||
|
serverOffsetFromDateMs: 0,
|
||||||
|
bestRttMs: Number.POSITIVE_INFINITY,
|
||||||
|
clockSamples: [],
|
||||||
|
scheduledJobs: new Map(),
|
||||||
|
frameCount: 0,
|
||||||
|
fps: 0,
|
||||||
|
frameTimeMs: 0,
|
||||||
|
droppedFrames: 0,
|
||||||
|
lastFrameAt: performance.now(),
|
||||||
|
lastFpsAt: performance.now(),
|
||||||
|
pageIndex: 0,
|
||||||
|
paused: false,
|
||||||
|
motionMode: "flow",
|
||||||
|
focusTarget: "core",
|
||||||
|
pulseActive: false,
|
||||||
|
clockText: "--:--:--",
|
||||||
|
hudText: "",
|
||||||
|
ctx: null,
|
||||||
|
isPreview,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
function clientNowMs() {
|
computed: {
|
||||||
return Date.now();
|
sceneTitle() {
|
||||||
}
|
return this.sceneDefinitions.find((scene) => scene.id === this.activeView)?.title || "LED 大屏投放平台";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
async function bootstrap() {
|
async mounted() {
|
||||||
const response = await fetch(`/api/bootstrap/${tileId}`);
|
if (!this.nodeId) {
|
||||||
const data = await response.json();
|
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
|
||||||
tile = data.tile;
|
this.nodeId = `${tileId}-${randomId}`;
|
||||||
applyState(data.state);
|
sessionStorage.setItem(nodeStorageKey, this.nodeId);
|
||||||
buildStaticContent();
|
}
|
||||||
layout();
|
this.ctx = this.$refs.motionCanvas.getContext("2d", { alpha: true });
|
||||||
connectWs();
|
await this.bootstrap();
|
||||||
requestAnimationFrame(renderLoop);
|
this.tickClock();
|
||||||
}
|
setInterval(() => this.tickClock(), 1000);
|
||||||
|
setInterval(() => this.syncClock(), 2000);
|
||||||
|
if (!isPreview) setInterval(() => this.sendTelemetry(), 1000);
|
||||||
|
requestAnimationFrame((now) => this.renderLoop(now));
|
||||||
|
},
|
||||||
|
|
||||||
function buildStaticContent() {
|
methods: {
|
||||||
document.querySelector("#overviewCards").innerHTML = [
|
async bootstrap() {
|
||||||
["同步模式", "时间轴"],
|
const response = await fetch(`/api/bootstrap/${tileId}`);
|
||||||
["渲染节点", "双 GPU"],
|
const data = await response.json();
|
||||||
["逻辑宽度", "14880"],
|
this.tile = data.tile;
|
||||||
["逻辑高度", "3510"],
|
this.applyState(data.state);
|
||||||
["半屏宽度", "7440"],
|
this.layout();
|
||||||
["目标帧率", "60"],
|
if (isPreview) {
|
||||||
].map(([label, value]) => `<article class="metric-card"><span>${label}</span><strong>${value}</strong></article>`).join("");
|
this.connectPreviewWs();
|
||||||
|
} else {
|
||||||
|
this.connectWs();
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", () => this.layout());
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key.toLowerCase() === "h") this.$refs.hud.hidden = !this.$refs.hud.hidden;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
const bars = document.querySelector("#timelineBars");
|
layout() {
|
||||||
bars.innerHTML = "";
|
if (!this.tile) return;
|
||||||
for (let index = 0; index < 32; index += 1) {
|
const scale = Math.min(window.innerWidth / this.tile.width, window.innerHeight / this.tile.height);
|
||||||
const bar = document.createElement("i");
|
const tileRenderWidth = this.tile.width * scale;
|
||||||
bar.style.height = `${180 + ((index * 137) % 760)}px`;
|
const tileRenderHeight = this.tile.height * scale;
|
||||||
bars.append(bar);
|
const root = document.documentElement;
|
||||||
}
|
root.style.setProperty("--wall-width", `${this.tile.wall_width}px`);
|
||||||
|
root.style.setProperty("--wall-height", `${this.tile.wall_height}px`);
|
||||||
|
root.style.setProperty("--tile-render-width", `${tileRenderWidth}px`);
|
||||||
|
root.style.setProperty("--tile-render-height", `${tileRenderHeight}px`);
|
||||||
|
root.style.setProperty("--scale", `${scale}`);
|
||||||
|
root.style.setProperty("--offset-x", `${-this.tile.x * scale}px`);
|
||||||
|
root.style.setProperty("--offset-y", `${-this.tile.y * scale}px`);
|
||||||
|
const appEl = document.querySelector("#outputApp") || (this.$el?.style ? this.$el : null);
|
||||||
|
const canvas = document.querySelector("#motionCanvas") || this.$refs.motionCanvas;
|
||||||
|
if (appEl) {
|
||||||
|
appEl.style.width = `${tileRenderWidth}px`;
|
||||||
|
appEl.style.height = `${tileRenderHeight}px`;
|
||||||
|
}
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = this.tile.wall_width;
|
||||||
|
canvas.height = this.tile.wall_height;
|
||||||
|
if (!this.ctx) this.ctx = canvas.getContext("2d", { alpha: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
|
applyState(nextState) {
|
||||||
const id = String(index + 1).padStart(2, "0");
|
this.state = nextState;
|
||||||
return `<article class="camera-card"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
|
this.activeView = nextState.active_view || "overview";
|
||||||
}).join("");
|
this.pulse();
|
||||||
|
},
|
||||||
|
|
||||||
document.querySelector("#alertRail").innerHTML = [
|
scheduleJob(message, kind, callback) {
|
||||||
"北侧通道人员聚集",
|
const applyAtMs = Number(message.payload.apply_at_ms);
|
||||||
"机房门禁异常",
|
this.scheduledJobs.set(message.command_id, {
|
||||||
"消防通道占用",
|
id: message.command_id,
|
||||||
"视频质量波动",
|
kind,
|
||||||
].map((text) => `<article class="alert-card">${text}</article>`).join("");
|
applyAtMs,
|
||||||
}
|
message,
|
||||||
|
callback,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
function layout() {
|
drainScheduledJobs() {
|
||||||
if (!tile) return;
|
if (!this.scheduledJobs.size) return;
|
||||||
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
|
const now = this.serverNowMs();
|
||||||
document.documentElement.style.setProperty("--wall-width", `${tile.wall_width}px`);
|
const ready = Array.from(this.scheduledJobs.values())
|
||||||
document.documentElement.style.setProperty("--wall-height", `${tile.wall_height}px`);
|
.filter((job) => now >= job.applyAtMs)
|
||||||
document.documentElement.style.setProperty("--scale", `${scale}`);
|
.sort((a, b) => a.applyAtMs - b.applyAtMs);
|
||||||
document.documentElement.style.setProperty("--offset-x", `${-tile.x * scale}px`);
|
for (const job of ready) {
|
||||||
document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`);
|
this.scheduledJobs.delete(job.id);
|
||||||
canvas.width = tile.wall_width;
|
const late = now - job.applyAtMs > 50;
|
||||||
canvas.height = tile.wall_height;
|
job.callback(late);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
function applyState(nextState) {
|
async prepareScene(message) {
|
||||||
state = nextState;
|
const startedAtMs = this.serverNowMs();
|
||||||
const view = state.active_view || "overview";
|
try {
|
||||||
document.querySelectorAll(".scene").forEach((scene) => {
|
await this.prepareSceneFrame(message);
|
||||||
scene.classList.toggle("active", scene.dataset.view === view);
|
this.sendAck(message, "prepared", {
|
||||||
});
|
prepare_duration_ms: Math.max(0, this.serverNowMs() - startedAtMs),
|
||||||
sceneTitle.textContent = sceneTitles[view] || sceneTitles.overview;
|
});
|
||||||
versionText.textContent = `v${state.version || 0}`;
|
} catch (error) {
|
||||||
pulse();
|
this.sendAck(message, "error", {
|
||||||
}
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
function scheduleJob(message, kind, callback) {
|
async prepareSceneFrame(message) {
|
||||||
const applyAtMs = Number(message.payload.apply_at_ms);
|
if (typeof window.ledPlatformPrepareScene === "function") {
|
||||||
scheduledJobs.set(message.command_id, {
|
await window.ledPlatformPrepareScene({
|
||||||
id: message.command_id,
|
commandId: message.command_id,
|
||||||
kind,
|
tile: this.tile,
|
||||||
applyAtMs,
|
state: message.payload.state,
|
||||||
message,
|
scene: message.payload.scene,
|
||||||
callback,
|
});
|
||||||
preparedAtMs: serverNowMs(),
|
return;
|
||||||
});
|
}
|
||||||
}
|
await Promise.all((message.payload.scene?.preload || []).map((url) => this.preloadUrl(url)));
|
||||||
|
await this.waitForFrames(2);
|
||||||
|
},
|
||||||
|
|
||||||
function drainScheduledJobs() {
|
preloadUrl(url) {
|
||||||
if (!scheduledJobs.size) return;
|
return new Promise((resolve) => {
|
||||||
const now = serverNowMs();
|
const image = new Image();
|
||||||
const ready = Array.from(scheduledJobs.values())
|
image.onload = resolve;
|
||||||
.filter((job) => now >= job.applyAtMs)
|
image.onerror = resolve;
|
||||||
.sort((a, b) => a.applyAtMs - b.applyAtMs);
|
image.src = url;
|
||||||
for (const job of ready) {
|
});
|
||||||
scheduledJobs.delete(job.id);
|
},
|
||||||
const late = now - job.applyAtMs > 50;
|
|
||||||
job.callback(late);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareScene(message) {
|
waitForFrames(count = 2) {
|
||||||
sendAck(message, "prepared");
|
return new Promise((resolve) => {
|
||||||
}
|
const step = (remaining) => {
|
||||||
|
if (remaining <= 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => step(remaining - 1));
|
||||||
|
};
|
||||||
|
step(count);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
function commitScene(message) {
|
commitScene(message) {
|
||||||
scheduleJob(message, "scene", (late) => {
|
this.scheduleJob(message, "scene", (late) => {
|
||||||
applyState(message.payload.state);
|
this.applyState(message.payload.state);
|
||||||
sendAck(message, late ? "late" : "committed");
|
this.sendAck(message, late ? "late" : "committed");
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
|
||||||
function runComponentAction(message) {
|
commitScenePreview(message) {
|
||||||
scheduleJob(message, "action", (late) => {
|
this.scheduleJob(message, "scene-preview", () => {
|
||||||
applyAction(message.payload.action, message.payload.args || {});
|
this.applyState(message.payload.state);
|
||||||
sendAck(message, late ? "late" : "action_committed");
|
});
|
||||||
});
|
},
|
||||||
}
|
|
||||||
|
|
||||||
function applyAction(action, args) {
|
runComponentAction(message) {
|
||||||
if (action === "page.next") {
|
this.scheduleJob(message, "action", (late) => {
|
||||||
pageIndex += 1;
|
this.applyAction(message.payload.action, message.payload.args || {});
|
||||||
const views = ["overview", "energy", "security"];
|
this.sendAck(message, late ? "late" : "action_committed");
|
||||||
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
});
|
||||||
} else if (action === "page.prev") {
|
},
|
||||||
pageIndex = Math.max(0, pageIndex - 1);
|
|
||||||
const views = ["overview", "energy", "security"];
|
|
||||||
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
|
||||||
} 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-card 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 timelineMs() {
|
runComponentActionPreview(message) {
|
||||||
if (!state) return 0;
|
this.scheduleJob(message, "action-preview", () => {
|
||||||
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
|
this.applyAction(message.payload.action, message.payload.args || {});
|
||||||
}
|
});
|
||||||
|
},
|
||||||
|
|
||||||
function renderLoop(now) {
|
applyAction(action, args) {
|
||||||
frameTimeMs = now - lastFrameAt;
|
if (action === "page.next" || action === "page.prev") {
|
||||||
if (frameTimeMs > 40) droppedFrames += 1;
|
const views = this.sceneDefinitions.map((scene) => scene.id);
|
||||||
frameCount += 1;
|
const direction = action === "page.next" ? 1 : -1;
|
||||||
if (now - lastFpsAt >= 1000) {
|
this.pageIndex = (views.indexOf(this.activeView) + direction + views.length) % views.length;
|
||||||
fps = frameCount * 1000 / (now - lastFpsAt);
|
this.applyState({
|
||||||
frameCount = 0;
|
...this.state,
|
||||||
lastFpsAt = now;
|
active_view: views[this.pageIndex],
|
||||||
}
|
version: (this.state?.version || 0) + 1,
|
||||||
lastFrameAt = now;
|
});
|
||||||
|
} else if (action === "energy.mode") {
|
||||||
|
this.updateMetric("energy", "调度策略", args.mode || "削峰", "manual");
|
||||||
|
} else if (action === "security.alert") {
|
||||||
|
this.prependEvent("security", {
|
||||||
|
time: this.clockText,
|
||||||
|
title: args.text || "新增联动告警",
|
||||||
|
detail: "结构化动作同步触发",
|
||||||
|
});
|
||||||
|
} else if (action === "motion.mode") {
|
||||||
|
this.motionMode = args.mode || "flow";
|
||||||
|
} else if (action === "scenario.focus") {
|
||||||
|
this.focusTarget = args.target || "core";
|
||||||
|
} else if (action === "timeline.pause") {
|
||||||
|
this.paused = true;
|
||||||
|
} else if (action === "timeline.resume") {
|
||||||
|
this.paused = false;
|
||||||
|
}
|
||||||
|
this.pulse();
|
||||||
|
},
|
||||||
|
|
||||||
drainScheduledJobs();
|
updateMetric(sceneId, label, value, trend) {
|
||||||
drawMotion(paused ? 0 : timelineMs());
|
const scene = this.sceneDefinitions.find((item) => item.id === sceneId);
|
||||||
animateBars(paused ? 0 : timelineMs());
|
const metric = scene?.metrics.find((item) => item.label === label);
|
||||||
updateHud();
|
if (!metric) return;
|
||||||
requestAnimationFrame(renderLoop);
|
metric.value = value;
|
||||||
}
|
metric.trend = trend;
|
||||||
|
},
|
||||||
|
|
||||||
function drawMotion(t) {
|
prependEvent(sceneId, event) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
const scene = this.sceneDefinitions.find((item) => item.id === sceneId);
|
||||||
ctx.globalAlpha = 0.75;
|
if (!scene) return;
|
||||||
for (let i = 0; i < 28; i += 1) {
|
scene.events.unshift(event);
|
||||||
const phase = t / 1200 + i * 0.43;
|
scene.events = scene.events.slice(0, 5);
|
||||||
const x = (Math.sin(phase * 0.46) * 0.5 + 0.5) * canvas.width;
|
},
|
||||||
const y = (Math.cos(phase * 0.37) * 0.5 + 0.5) * canvas.height;
|
|
||||||
const radius = 260 + (i % 6) * 70;
|
|
||||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.4);
|
|
||||||
gradient.addColorStop(0, i % 2 ? "rgba(249,115,22,0.18)" : "rgba(20,184,166,0.22)");
|
|
||||||
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
||||||
ctx.fillStyle = gradient;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y, radius * 3.4, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateBars(t) {
|
timelineMs() {
|
||||||
document.querySelectorAll("#timelineBars i").forEach((bar, index) => {
|
if (!this.state) return 0;
|
||||||
const scale = 0.82 + (Math.sin(t / 720 + index * 0.38) + 1) * 0.16;
|
return Math.max(0, this.serverNowMs() - this.state.scene_started_at_ms);
|
||||||
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
},
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pulse() {
|
renderLoop(now) {
|
||||||
wall.classList.remove("pulse");
|
this.frameTimeMs = now - this.lastFrameAt;
|
||||||
requestAnimationFrame(() => wall.classList.add("pulse"));
|
if (this.frameTimeMs > 40) this.droppedFrames += 1;
|
||||||
}
|
this.frameCount += 1;
|
||||||
|
if (now - this.lastFpsAt >= 1000) {
|
||||||
|
this.fps = this.frameCount * 1000 / (now - this.lastFpsAt);
|
||||||
|
this.frameCount = 0;
|
||||||
|
this.lastFpsAt = now;
|
||||||
|
}
|
||||||
|
this.lastFrameAt = now;
|
||||||
|
|
||||||
function syncClock() {
|
this.drainScheduledJobs();
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
this.drawMotion(this.paused ? 0 : this.timelineMs());
|
||||||
ws.send(JSON.stringify({
|
this.updateHud();
|
||||||
type: "clock_ping",
|
requestAnimationFrame((nextNow) => this.renderLoop(nextNow));
|
||||||
node_id: nodeId,
|
},
|
||||||
client_send_ms: clientNowMs(),
|
|
||||||
client_send_perf_ms: performance.now(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClockPong(payload) {
|
drawMotion(t) {
|
||||||
const receivePerf = performance.now();
|
const ctx = this.ctx;
|
||||||
const sendPerf = Number(payload.client_send_perf_ms ?? payload.client_send_ms);
|
if (!ctx || !this.tile) return;
|
||||||
if (!Number.isFinite(sendPerf)) return;
|
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||||
const rtt = receivePerf - sendPerf;
|
const count = this.motionMode === "orbit" ? 46 : this.motionMode === "pulse" ? 34 : 28;
|
||||||
const midpointPerf = sendPerf + rtt / 2;
|
ctx.globalAlpha = 0.72;
|
||||||
const offset = Number(payload.server_time_ms) - midpointPerf;
|
for (let i = 0; i < count; i += 1) {
|
||||||
clockSamples.push({ rtt, offset });
|
const modeFactor = this.motionMode === "pulse" ? 0.84 : this.motionMode === "orbit" ? 1.24 : 1;
|
||||||
clockSamples = clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8);
|
const phase = t / (980 / modeFactor) + i * 0.39;
|
||||||
bestRttMs = clockSamples[0].rtt;
|
const x = (Math.sin(phase * 0.41 + i) * 0.5 + 0.5) * ctx.canvas.width;
|
||||||
serverOffsetFromPerfMs = clockSamples.slice(0, 4).reduce((sum, item) => sum + item.offset, 0) / Math.min(4, clockSamples.length);
|
const y = (Math.cos(phase * 0.33 + i * 0.2) * 0.5 + 0.5) * ctx.canvas.height;
|
||||||
}
|
const radius = 240 + (i % 7) * 74;
|
||||||
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.2);
|
||||||
|
gradient.addColorStop(0, i % 3 ? "rgba(56,189,248,0.22)" : "rgba(20,184,166,0.24)");
|
||||||
|
gradient.addColorStop(0.42, "rgba(37,99,235,0.08)");
|
||||||
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius * 3.2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
},
|
||||||
|
|
||||||
function sendAck(message, status) {
|
pulse() {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
this.pulseActive = false;
|
||||||
ws.send(JSON.stringify({
|
requestAnimationFrame(() => {
|
||||||
type: "ack",
|
this.pulseActive = true;
|
||||||
command_id: message.command_id,
|
setTimeout(() => {
|
||||||
node_id: nodeId,
|
this.pulseActive = false;
|
||||||
tile_id: tile.id,
|
}, 560);
|
||||||
status,
|
});
|
||||||
client_time_ms: clientNowMs(),
|
},
|
||||||
server_estimated_ms: serverNowMs(),
|
|
||||||
clock_offset_ms: serverOffsetFromPerfMs - (Date.now() - performance.now()),
|
|
||||||
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendTelemetry() {
|
syncClock() {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||||
ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
type: "telemetry",
|
type: "clock_ping",
|
||||||
node_id: nodeId,
|
node_id: this.nodeId,
|
||||||
tile_id: tile.id,
|
client_send_ms: Date.now(),
|
||||||
fps,
|
client_send_perf_ms: performance.now(),
|
||||||
frame_time_ms: frameTimeMs,
|
}));
|
||||||
dropped_frames: droppedFrames,
|
},
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWs() {
|
handleClockPong(payload) {
|
||||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
const receivePerf = performance.now();
|
||||||
ws = new WebSocket(`${protocol}://${location.host}/ws/output/${tile.id}`);
|
const sendPerf = Number(payload.client_send_perf_ms);
|
||||||
ws.addEventListener("open", () => {
|
const sendWall = Number(payload.client_send_ms);
|
||||||
ws.send(JSON.stringify({
|
const serverTime = Number(payload.server_time_ms);
|
||||||
type: "hello",
|
if (!Number.isFinite(sendWall) || !Number.isFinite(serverTime)) return;
|
||||||
node_id: nodeId,
|
const perfRtt = Number.isFinite(sendPerf) ? receivePerf - sendPerf : Number.NaN;
|
||||||
tile_id: tile.id,
|
const wallRtt = Date.now() - sendWall;
|
||||||
app_version: APP_VERSION,
|
const rtt = Number.isFinite(perfRtt) && perfRtt >= 0 ? perfRtt : wallRtt;
|
||||||
user_agent: navigator.userAgent,
|
const midpointWall = sendWall + rtt / 2;
|
||||||
}));
|
const offset = serverTime - midpointWall;
|
||||||
for (let i = 0; i < 5; i += 1) setTimeout(syncClock, i * 120);
|
this.clockSamples.push({ rtt, offset });
|
||||||
});
|
this.clockSamples = this.clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8);
|
||||||
ws.addEventListener("close", () => setTimeout(connectWs, 1200));
|
this.bestRttMs = this.clockSamples[0].rtt;
|
||||||
ws.addEventListener("message", (event) => {
|
this.serverOffsetFromDateMs = this.clockSamples
|
||||||
const message = JSON.parse(event.data);
|
.slice(0, 4)
|
||||||
if (message.type === "clock_pong") handleClockPong(message.payload);
|
.reduce((sum, item) => sum + item.offset, 0) / Math.min(4, this.clockSamples.length);
|
||||||
if (message.type === "state") scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => applyState(message.payload.state || message.payload));
|
},
|
||||||
if (message.type === "prepare_scene") prepareScene(message);
|
|
||||||
if (message.type === "commit_scene") commitScene(message);
|
|
||||||
if (message.type === "component_action") runComponentAction(message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHud() {
|
serverNowMs() {
|
||||||
if (!tile) return;
|
return Date.now() + this.serverOffsetFromDateMs;
|
||||||
const rtt = Number.isFinite(bestRttMs) ? `${Math.round(bestRttMs)}ms` : "--";
|
},
|
||||||
const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale"));
|
|
||||||
const preview = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : "";
|
|
||||||
hud.innerHTML = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${scheduledJobs.size}<span class="preview-note">${preview}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tickClock() {
|
sendAck(message, status, extra = {}) {
|
||||||
clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return;
|
||||||
}
|
this.ws.send(JSON.stringify({
|
||||||
|
type: "ack",
|
||||||
|
command_id: message.command_id,
|
||||||
|
node_id: this.nodeId,
|
||||||
|
tile_id: this.tile.id,
|
||||||
|
status,
|
||||||
|
client_time_ms: Date.now(),
|
||||||
|
server_estimated_ms: this.serverNowMs(),
|
||||||
|
clock_offset_ms: this.serverOffsetFromDateMs,
|
||||||
|
rtt_ms: Number.isFinite(this.bestRttMs) ? this.bestRttMs : null,
|
||||||
|
...extra,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
window.addEventListener("resize", layout);
|
sendTelemetry() {
|
||||||
window.addEventListener("keydown", (event) => {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return;
|
||||||
if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden;
|
this.ws.send(JSON.stringify({
|
||||||
});
|
type: "telemetry",
|
||||||
|
node_id: this.nodeId,
|
||||||
|
tile_id: this.tile.id,
|
||||||
|
fps: this.fps,
|
||||||
|
frame_time_ms: this.frameTimeMs,
|
||||||
|
dropped_frames: this.droppedFrames,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
bootstrap();
|
connectWs() {
|
||||||
tickClock();
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
setInterval(tickClock, 1000);
|
this.ws = new WebSocket(`${protocol}://${location.host}/ws/output/${this.tile.id}`);
|
||||||
setInterval(syncClock, 2000);
|
this.ws.addEventListener("open", () => {
|
||||||
setInterval(sendTelemetry, 1000);
|
this.ws.send(JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
node_id: this.nodeId,
|
||||||
|
tile_id: this.tile.id,
|
||||||
|
app_version: APP_VERSION,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
}));
|
||||||
|
for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120);
|
||||||
|
});
|
||||||
|
this.ws.addEventListener("close", () => setTimeout(() => this.connectWs(), 1200));
|
||||||
|
this.ws.addEventListener("message", (event) => this.handleOutputMessage(JSON.parse(event.data)));
|
||||||
|
},
|
||||||
|
|
||||||
|
connectPreviewWs() {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
this.ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
|
||||||
|
this.ws.addEventListener("open", () => {
|
||||||
|
for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120);
|
||||||
|
});
|
||||||
|
this.ws.addEventListener("close", () => setTimeout(() => this.connectPreviewWs(), 1200));
|
||||||
|
this.ws.addEventListener("message", (event) => this.handlePreviewMessage(JSON.parse(event.data)));
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOutputMessage(message) {
|
||||||
|
if (message.type === "clock_pong") this.handleClockPong(message.payload);
|
||||||
|
if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload));
|
||||||
|
if (message.type === "prepare_scene") void this.prepareScene(message);
|
||||||
|
if (message.type === "commit_scene") this.commitScene(message);
|
||||||
|
if (message.type === "component_action") this.runComponentAction(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePreviewMessage(message) {
|
||||||
|
if (message.type === "clock_pong") this.handleClockPong(message.payload);
|
||||||
|
if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload));
|
||||||
|
if (message.type === "prepare_scene") this.commitScenePreview(message);
|
||||||
|
if (message.type === "commit_scene") this.commitScenePreview(message);
|
||||||
|
if (message.type === "component_action") this.runComponentActionPreview(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHud() {
|
||||||
|
if (!this.tile) return;
|
||||||
|
const rtt = Number.isFinite(this.bestRttMs) ? `${Math.round(this.bestRttMs)}ms` : "--";
|
||||||
|
const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale"));
|
||||||
|
const previewNote = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : "";
|
||||||
|
const mode = isPreview ? "PREVIEW" : this.nodeId.slice(0, 18);
|
||||||
|
this.hudText = `${this.tile.id} | ${mode} | ${this.motionMode} | ${this.fps.toFixed(1)}fps | ${this.frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${this.scheduledJobs.size}${previewNote}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
tickClock() {
|
||||||
|
this.clockText = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).mount("#outputApp");
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
|
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
|
||||||
background: #030712;
|
background: #020617;
|
||||||
color: #f8fbff;
|
color: #f8fbff;
|
||||||
--wall-width: 14880px;
|
--wall-width: 14880px;
|
||||||
--wall-height: 3510px;
|
--wall-height: 3510px;
|
||||||
|
--tile-render-width: 100vw;
|
||||||
|
--tile-render-height: 100vh;
|
||||||
--scale: 1;
|
--scale: 1;
|
||||||
--offset-x: 0px;
|
--offset-x: 0px;
|
||||||
--offset-y: 0px;
|
--offset-y: 0px;
|
||||||
@@ -14,9 +16,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[v-cloak] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body {
|
||||||
.viewport {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -24,54 +29,70 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: #030712;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
position: fixed;
|
position: relative;
|
||||||
inset: 0;
|
width: var(--tile-render-width);
|
||||||
background: #030712;
|
height: var(--tile-render-height);
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wall {
|
.wall {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: var(--offset-x);
|
||||||
top: 0;
|
top: var(--offset-y);
|
||||||
width: var(--wall-width);
|
width: var(--wall-width);
|
||||||
height: var(--wall-height);
|
height: var(--wall-height);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
|
transform: scale(var(--scale));
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 40%, rgba(20, 184, 166, 0.28), transparent 36%),
|
radial-gradient(circle at 14% 16%, rgba(14, 165, 233, 0.22), transparent 23%),
|
||||||
radial-gradient(circle at 82% 22%, rgba(249, 115, 22, 0.18), transparent 24%),
|
radial-gradient(circle at 78% 28%, rgba(20, 184, 166, 0.18), transparent 26%),
|
||||||
linear-gradient(135deg, #061528 0%, #0b1220 48%, #071018 100%);
|
linear-gradient(135deg, #020b1f 0%, #06172d 46%, #020617 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.motion-canvas,
|
.motion-canvas,
|
||||||
.grid-layer {
|
.grid-layer,
|
||||||
|
.scan-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-layer {
|
.grid-layer {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(255,255,255,0.045) 2px, transparent 2px),
|
linear-gradient(rgba(125, 211, 252, 0.045) 2px, transparent 2px),
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.045) 2px, transparent 2px);
|
linear-gradient(90deg, rgba(125, 211, 252, 0.045) 2px, transparent 2px);
|
||||||
background-size: 240px 240px;
|
background-size: 240px 240px;
|
||||||
opacity: 0.74;
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-layer {
|
||||||
|
opacity: 0.34;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 0 47%, rgba(56, 189, 248, 0.16) 50%, transparent 53%),
|
||||||
|
repeating-linear-gradient(0deg, transparent 0 120px, rgba(125, 211, 252, 0.035) 124px 128px);
|
||||||
|
background-size: 2400px 100%, 100% 100%;
|
||||||
|
animation: scan 10s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.screen-header {
|
.screen-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 4;
|
z-index: 5;
|
||||||
left: 360px;
|
left: 340px;
|
||||||
right: 360px;
|
right: 340px;
|
||||||
top: 180px;
|
top: 160px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 120px;
|
gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -82,44 +103,47 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin-bottom: 38px;
|
margin-bottom: 34px;
|
||||||
color: #5eead4;
|
color: #67e8f9;
|
||||||
font-size: 58px;
|
font-size: 54px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 178px;
|
color: #f8fbff;
|
||||||
|
font-size: 172px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-weight: 820;
|
font-weight: 860;
|
||||||
color: #ffffff;
|
text-shadow: 0 0 44px rgba(56, 189, 248, 0.5);
|
||||||
text-shadow: 0 0 36px rgba(20, 184, 166, 0.46);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-metrics {
|
.header-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
gap: 26px;
|
gap: 24px;
|
||||||
|
color: #dbeafe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-metrics strong {
|
.header-metrics strong {
|
||||||
font-size: 112px;
|
font-size: 108px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-metrics span {
|
.header-metrics span,
|
||||||
color: #cbd5e1;
|
.header-metrics em {
|
||||||
font-size: 52px;
|
font-size: 48px;
|
||||||
|
font-style: normal;
|
||||||
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene {
|
.scene {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 4;
|
||||||
inset: 620px 360px 250px;
|
inset: 580px 340px 230px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(54px);
|
transform: translateY(70px);
|
||||||
transition: opacity 260ms linear, transform 260ms linear;
|
transition: opacity 260ms linear, transform 260ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,209 +154,227 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
max-width: 6200px;
|
max-width: 6400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy span {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 34px;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 56px;
|
||||||
|
font-weight: 760;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy h2 {
|
.hero-copy h2 {
|
||||||
font-size: 158px;
|
font-size: 156px;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
font-weight: 820;
|
font-weight: 860;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy p {
|
.hero-copy p {
|
||||||
margin-top: 44px;
|
margin-top: 42px;
|
||||||
|
max-width: 5800px;
|
||||||
color: #dbeafe;
|
color: #dbeafe;
|
||||||
font-size: 66px;
|
font-size: 64px;
|
||||||
line-height: 1.34;
|
line-height: 1.32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.metric-grid {
|
||||||
margin-top: 150px;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 760px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 72px;
|
gap: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card,
|
.metric-card,
|
||||||
.side-stats article,
|
.stage-node,
|
||||||
.camera-card {
|
.event-card {
|
||||||
border: 4px solid rgba(94, 234, 212, 0.32);
|
border: 4px solid rgba(125, 211, 252, 0.28);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(15, 23, 42, 0.76);
|
background:
|
||||||
box-shadow: inset 0 0 60px rgba(20, 184, 166, 0.10), 0 0 42px rgba(20, 184, 166, 0.12);
|
linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(8, 47, 73, 0.62)),
|
||||||
|
rgba(15, 23, 42, 0.8);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 76px rgba(14, 165, 233, 0.13),
|
||||||
|
0 0 54px rgba(14, 165, 233, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
min-height: 520px;
|
min-height: 430px;
|
||||||
padding: 74px;
|
padding: 62px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card span,
|
.metric-card span,
|
||||||
.side-stats span {
|
.event-card span {
|
||||||
display: block;
|
display: block;
|
||||||
color: #a7f3d0;
|
color: #7dd3fc;
|
||||||
font-size: 58px;
|
font-size: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card strong,
|
.metric-card strong {
|
||||||
.side-stats strong {
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 48px;
|
margin-top: 36px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 136px;
|
font-size: 122px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-bars {
|
.metric-card small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 28px;
|
||||||
|
color: #a7f3d0;
|
||||||
|
font-size: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-stage {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
top: 1360px;
|
||||||
bottom: 0;
|
width: 11200px;
|
||||||
height: 960px;
|
height: 1320px;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(32, 1fr);
|
|
||||||
align-items: end;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-bars i {
|
.route-field {
|
||||||
min-height: 120px;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
background: linear-gradient(180deg, #14b8a6, #f97316);
|
|
||||||
transform-origin: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-map {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset: 0;
|
||||||
top: 790px;
|
|
||||||
width: 8600px;
|
|
||||||
height: 1760px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-line {
|
.route-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
|
background: linear-gradient(90deg, transparent, #38bdf8, #2dd4bf, transparent);
|
||||||
box-shadow: 0 0 90px rgba(20, 184, 166, 0.46);
|
box-shadow: 0 0 80px rgba(56, 189, 248, 0.46);
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: routePulse 3.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line-a {
|
.line-a { left: 620px; top: 210px; width: 6000px; transform: rotate(8deg); }
|
||||||
left: 1100px;
|
.line-b { left: 980px; top: 680px; width: 7600px; transform: rotate(-7deg); }
|
||||||
top: 690px;
|
.line-c { left: 2600px; top: 1020px; width: 6400px; transform: rotate(5deg); }
|
||||||
width: 5200px;
|
.line-d { left: 1400px; top: 420px; width: 8800px; transform: rotate(0deg); }
|
||||||
transform: rotate(7deg);
|
.line-e { left: 2200px; top: 840px; width: 6600px; transform: rotate(-12deg); }
|
||||||
}
|
.line-f { left: 540px; top: 1060px; width: 9200px; transform: rotate(10deg); }
|
||||||
|
|
||||||
.line-b {
|
.stage-node {
|
||||||
left: 2300px;
|
|
||||||
top: 1050px;
|
|
||||||
width: 4200px;
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flow-node {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 980px;
|
width: 1120px;
|
||||||
height: 420px;
|
min-height: 390px;
|
||||||
|
padding: 60px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
align-content: center;
|
||||||
border: 5px solid rgba(94, 234, 212, 0.62);
|
gap: 26px;
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(8, 20, 28, 0.92);
|
|
||||||
font-size: 80px;
|
|
||||||
font-weight: 780;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-a { left: 220px; top: 520px; }
|
.stage-node span {
|
||||||
.node-b { left: 3600px; top: 220px; }
|
color: #bfdbfe;
|
||||||
.node-c { left: 7020px; top: 900px; }
|
font-size: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
.side-stats {
|
.stage-node strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 84px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.cyan {
|
||||||
|
border-color: rgba(34, 211, 238, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.blue {
|
||||||
|
border-color: rgba(96, 165, 250, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-node.orange {
|
||||||
|
border-color: rgba(251, 146, 60, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 690px;
|
top: 1340px;
|
||||||
width: 4700px;
|
width: 3600px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
gap: 34px;
|
||||||
gap: 64px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-stats article {
|
.event-card {
|
||||||
min-height: 620px;
|
padding: 46px 56px;
|
||||||
padding: 78px;
|
border-left: 18px solid #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-grid {
|
.event-card strong {
|
||||||
position: absolute;
|
display: block;
|
||||||
left: 0;
|
margin-top: 18px;
|
||||||
top: 760px;
|
color: #ffffff;
|
||||||
width: 9200px;
|
font-size: 60px;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 52px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-card {
|
.event-card small {
|
||||||
height: 700px;
|
display: block;
|
||||||
padding: 58px;
|
margin-top: 18px;
|
||||||
display: grid;
|
color: #cbd5e1;
|
||||||
align-content: space-between;
|
font-size: 42px;
|
||||||
background:
|
|
||||||
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
|
|
||||||
rgba(15,23,42,0.78);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-card strong {
|
.motion-pulse .route-line {
|
||||||
font-size: 78px;
|
animation-duration: 1.8s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera-card span {
|
.motion-orbit .stage-node {
|
||||||
color: #dbeafe;
|
animation: nodeFloat 4.6s ease-in-out infinite;
|
||||||
font-size: 52px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-rail {
|
.focus-edge .stage-node.orange,
|
||||||
position: absolute;
|
.focus-core .stage-node.cyan {
|
||||||
right: 0;
|
box-shadow:
|
||||||
top: 740px;
|
inset 0 0 96px rgba(125, 211, 252, 0.22),
|
||||||
width: 4300px;
|
0 0 120px rgba(56, 189, 248, 0.38);
|
||||||
display: grid;
|
|
||||||
gap: 44px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-card {
|
.theme-energy .event-card { border-left-color: #2dd4bf; }
|
||||||
padding: 58px 68px;
|
.theme-security .event-card { border-left-color: #fb923c; }
|
||||||
border-left: 20px solid #f97316;
|
.theme-command .event-card { border-left-color: #60a5fa; }
|
||||||
border-radius: 8px;
|
.theme-transport .event-card { border-left-color: #38bdf8; }
|
||||||
background: rgba(15, 23, 42, 0.78);
|
.theme-dataflow .event-card { border-left-color: #22d3ee; }
|
||||||
font-size: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
max-width: min(980px, calc(100vw - 24px));
|
max-width: min(1120px, calc(100vw - 24px));
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0,0,0,0.72);
|
background: rgba(0, 0, 0, 0.72);
|
||||||
color: rgba(255,255,255,0.82);
|
color: rgba(255, 255, 255, 0.84);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-note {
|
.pulse {
|
||||||
color: #facc15;
|
animation: flash 560ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse {
|
@keyframes scan {
|
||||||
animation: flash 520ms ease;
|
from { background-position: -2400px 0, 0 0; }
|
||||||
|
to { background-position: 2400px 0, 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes routePulse {
|
||||||
|
0%, 100% { opacity: 0.36; filter: brightness(0.9); }
|
||||||
|
50% { opacity: 1; filter: brightness(1.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nodeFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-26px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flash {
|
@keyframes flash {
|
||||||
0% { outline: 18px solid rgba(94, 234, 212, 0.55); }
|
0% { outline: 18px solid rgba(56, 189, 248, 0.55); }
|
||||||
100% { outline: 0 solid rgba(94, 234, 212, 0); }
|
100% { outline: 0 solid rgba(56, 189, 248, 0); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,38 @@ def test_sync_coordinator_marks_command_complete_after_both_tiles_ack():
|
|||||||
assert coordinator.status()["commands"][0]["complete"]
|
assert coordinator.status()["commands"][0]["complete"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_coordinator_releases_scene_commit_after_both_tiles_prepare():
|
||||||
|
coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30)
|
||||||
|
message = CommandEnvelope(
|
||||||
|
type=CommandType.COMMIT_SCENE,
|
||||||
|
payload={"apply_at_ms": 2000, "state": {"active_scene_id": "overview"}},
|
||||||
|
)
|
||||||
|
coordinator.register_command(message, message.payload)
|
||||||
|
|
||||||
|
coordinator.ack(
|
||||||
|
{
|
||||||
|
"command_id": message.command_id,
|
||||||
|
"node_id": "left-node",
|
||||||
|
"tile_id": "left",
|
||||||
|
"status": "prepared",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert coordinator.release_if_prepared(message.command_id) is None
|
||||||
|
|
||||||
|
coordinator.ack(
|
||||||
|
{
|
||||||
|
"command_id": message.command_id,
|
||||||
|
"node_id": "right-node",
|
||||||
|
"tile_id": "right",
|
||||||
|
"status": "prepared",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
released = coordinator.release_if_prepared(message.command_id)
|
||||||
|
assert released is not None
|
||||||
|
assert released.released_at_ms is not None
|
||||||
|
assert coordinator.release_if_prepared(message.command_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_sync_coordinator_tracks_node_clock_and_frame_metrics():
|
def test_sync_coordinator_tracks_node_clock_and_frame_metrics():
|
||||||
coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30)
|
coordinator = SyncCoordinator(["left", "right"], command_ttl_seconds=30)
|
||||||
coordinator.connect_node(node_id="left-node", tile_id="left", app_version="test")
|
coordinator.connect_node(node_id="left-node", tile_id="left", app_version="test")
|
||||||
|
|||||||
Reference in New Issue
Block a user