feat: TurboSu initial release - racing telemetry dashboard
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
logs/*.log
|
||||||
|
data/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
感谢你对 TurboSu 的关注!本指南将帮助你参与项目开发。
|
||||||
|
|
||||||
|
## 贡献方式
|
||||||
|
|
||||||
|
### 提交游戏插件
|
||||||
|
|
||||||
|
如果你想让 TurboSu 支持一款新的赛车游戏,只需创建一个游戏插件:
|
||||||
|
|
||||||
|
1. 在 `games/user/` 下新建文件夹 (以游戏 ID 命名)
|
||||||
|
2. 编写 `manifest.json` (元信息) 和 `parser.py` (数据解析)
|
||||||
|
3. 提交 Pull Request 到 `games/user/` 目录,或直接在设置页面通过 "导入插件" 安装
|
||||||
|
|
||||||
|
插件结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
games/user/<game_id>/
|
||||||
|
├── manifest.json # 游戏元信息
|
||||||
|
└── parser.py # 解析逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
#### manifest.json 格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "unique_game_id",
|
||||||
|
"name": "游戏名称",
|
||||||
|
"parser_type": "forza",
|
||||||
|
"telemetry_format": "fh5",
|
||||||
|
"description": "简短描述",
|
||||||
|
"author": "你的名字",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### parser.py 规范
|
||||||
|
|
||||||
|
必须实现 `get_parser()` 函数,返回一个包含以下方法的对象:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class MyParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
"""返回与 manifest.json 一致的 game id"""
|
||||||
|
return "unique_game_id"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
"""
|
||||||
|
解析 UDP 数据包
|
||||||
|
data: 原始字节数据
|
||||||
|
addr: (host, port) 发送方地址
|
||||||
|
返回: server.telemetry.data.TelemetryData 对象
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TelemetryData 可用字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| speed_kmh | float | 速度 (km/h) |
|
||||||
|
| speed_mph | float | 速度 (mph) |
|
||||||
|
| rpm | float | 发动机转速 |
|
||||||
|
| max_rpm | float | 最大转速 |
|
||||||
|
| gear | int | 当前档位 (0=N, 1-8, -1=R) |
|
||||||
|
| throttle | float | 油门 (0-1) |
|
||||||
|
| brake | float | 刹车 (0-1) |
|
||||||
|
| clutch | float | 离合 (0-1) |
|
||||||
|
| handbrake | float | 手刹 (0-1) |
|
||||||
|
| steering | float | 转向 (-1 to 1) |
|
||||||
|
| lap_time | float | 当前圈速 (秒) |
|
||||||
|
| best_lap | float | 最佳圈速 (秒) |
|
||||||
|
| last_lap | float | 上一圈速 (秒) |
|
||||||
|
| lap_number | int | 圈数 |
|
||||||
|
| fuel | float | 燃油量 |
|
||||||
|
| boost | float | 涡轮增压值 |
|
||||||
|
| horsepower | float | 马力 |
|
||||||
|
| torque | float | 扭矩 |
|
||||||
|
| position_x/y/z | float | 车辆坐标 |
|
||||||
|
| engine_temp | float | 发动机温度 |
|
||||||
|
| oil_temp | float | 油温 |
|
||||||
|
| raw | dict | 原始数据 (自动记录) |
|
||||||
|
|
||||||
|
### 提交仪表盘主题
|
||||||
|
|
||||||
|
1. 在 TurboSu 设置页面创建/编辑仪表盘
|
||||||
|
2. 导出为 JSON 文件
|
||||||
|
3. 提交到 `dashboards/` 目录
|
||||||
|
|
||||||
|
### 报告 Bug
|
||||||
|
|
||||||
|
在 GitHub Issues 中提交,请包含:
|
||||||
|
- 操作系统和 Python 版本
|
||||||
|
- 错误日志 (logs/turbosu.log)
|
||||||
|
- 复现步骤
|
||||||
|
|
||||||
|
### 功能建议
|
||||||
|
|
||||||
|
欢迎提交 Issue 讨论新功能方向。
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Project/TurboSu
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
- Python 代码遵循 PEP 8
|
||||||
|
- 使用 `utils.logger` 进行日志记录(不要用 print)
|
||||||
|
- 前端 JS 使用模块化结构,文件放在对应目录下
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
贡献即表示同意你的代码在 Apache 2.0 许可下发布。
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# TurboSu
|
||||||
|
|
||||||
|
赛车遥测仪表盘 — 基于 Web 的 B/S 架构外置仪表盘,支持多种赛车游戏遥测数据实时显示。
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **多游戏支持** —— 插件化架构,内置支持 Forza Horizon 4/5、Forza Motorsport、Assetto Corsa/ACC、F1、iRacing
|
||||||
|
- **社区扩展** —— 游戏插件可独立导入导出,方便社区贡献小众游戏支持
|
||||||
|
- **实时仪表盘** —— 速度表、转速表、档位指示器、圈速计时器等
|
||||||
|
- **场景模式** —— 多仪表盘布局组合,支持多比例画布切换
|
||||||
|
- **局域网共享** —— 仪表盘/场景生成独立链接,手机/平板/笔记本均可全屏访问
|
||||||
|
- **跨端自适应** —— 响应式布局,桌面侧边栏 / 平板折叠 / 手机底部导航
|
||||||
|
- **比例约束渲染** —— 每个仪表盘可指定渲染比例(auto/16:9/4:3/1:1)和渲染模式
|
||||||
|
- **主题切换** —— Xiaomi HyperOS (Miuix) 风格,支持日间/夜间模式
|
||||||
|
- **导出导入** —— 仪表盘主题和场景独立配置文件,社区友好
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- pip
|
||||||
|
|
||||||
|
### 安装运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆/下载项目
|
||||||
|
cd Project/TurboSu
|
||||||
|
|
||||||
|
# 创建虚拟环境
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://localhost:9527`
|
||||||
|
|
||||||
|
### 一键启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x run.sh
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### 1. 选择游戏
|
||||||
|
|
||||||
|
侧边栏 → "选择游戏" 下拉 → 点击你要玩的游戏。
|
||||||
|
|
||||||
|
游戏列表来自 `games/builtin/` (内置) 和 `games/user/` (社区插件)。
|
||||||
|
|
||||||
|
### 2. 配置游戏遥测输出
|
||||||
|
|
||||||
|
在各游戏中设置 UDP 数据输出:
|
||||||
|
|
||||||
|
| 游戏 | 设置位置 | 端口 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Forza Horizon 4/5 | 设置 → HUD与游戏 → 数据输出 | 20777 |
|
||||||
|
| Forza Motorsport | 设置 → 游戏玩法 → UDP 数据输出 | 20777 |
|
||||||
|
| Assetto Corsa | 内容管理器 → 设置 → 自定义UDP | 9996 |
|
||||||
|
| ACC | 设置 → 电子设备 → UDP | 20777 |
|
||||||
|
| F1 24 | 设置 → 遥测设置 → UDP | 20777 |
|
||||||
|
| iRacing | 选项 → 杂项 → 数据记录 | 20777 |
|
||||||
|
|
||||||
|
### 3. 浏览仪表盘
|
||||||
|
|
||||||
|
点击侧边栏 "仪表盘" → 浏览/筛选 → 点击卡片在新标签打开。
|
||||||
|
|
||||||
|
链接会自动复制,可在局域网设备(手机/平板)打开全屏显示。
|
||||||
|
|
||||||
|
### 4. 创建场景
|
||||||
|
|
||||||
|
侧边栏 → "场景" → 新建 → 编辑添加多个仪表盘 → 渲染。
|
||||||
|
|
||||||
|
场景支持多个画布比例(16:9/4:3等),渲染前选择。
|
||||||
|
|
||||||
|
### 5. 数据调试
|
||||||
|
|
||||||
|
侧边栏 → "数据测试" → 查看解析后的实时数据和原始数据流。
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
### 创建自定义游戏插件
|
||||||
|
|
||||||
|
1. 在 `games/user/` 下创建文件夹,如 `games/user/my_game/`
|
||||||
|
2. 创建 `manifest.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my_game",
|
||||||
|
"name": "我的游戏",
|
||||||
|
"parser_type": "forza",
|
||||||
|
"description": "自定义游戏遥测",
|
||||||
|
"author": "你的名字",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 创建 `parser.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return MyGameParser()
|
||||||
|
|
||||||
|
|
||||||
|
class MyGameParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "my_game"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="my_game", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data)}
|
||||||
|
|
||||||
|
# 解析你的游戏数据
|
||||||
|
# td.speed_kmh = ...
|
||||||
|
# td.rpm = ...
|
||||||
|
# td.gear = ...
|
||||||
|
|
||||||
|
return td
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 重启 TurboSu 或点击设置 → 重新加载插件
|
||||||
|
|
||||||
|
### 创建自定义仪表盘主题
|
||||||
|
|
||||||
|
在设置页面的仪表盘管理中创建,或直接编写配置文件。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
TurboSu/
|
||||||
|
├── app.py # FastAPI 主入口
|
||||||
|
├── config/settings.py # 全局配置管理
|
||||||
|
├── server/
|
||||||
|
│ ├── api.py # REST API 路由
|
||||||
|
│ ├── websocket.py # WebSocket 实时推送
|
||||||
|
│ ├── game_manager.py # 游戏插件管理器
|
||||||
|
│ └── telemetry/
|
||||||
|
│ ├── data.py # 遥测数据结构
|
||||||
|
│ ├── listener.py # UDP 监听器
|
||||||
|
│ └── parsers/ # 内置解析器
|
||||||
|
├── games/
|
||||||
|
│ ├── builtin/ # 内置游戏插件 (7款)
|
||||||
|
│ └── user/ # 用户/社区插件
|
||||||
|
├── models/
|
||||||
|
│ ├── dashboard.py # 仪表盘数据模型
|
||||||
|
│ └── scene.py # 场景数据模型
|
||||||
|
├── dashboards/ # 内置仪表盘主题 (4款)
|
||||||
|
├── templates/
|
||||||
|
│ ├── index.html # 主 SPA 页面
|
||||||
|
│ ├── dashboard.html # 仪表盘渲染页
|
||||||
|
│ └── scene.html # 场景渲染页
|
||||||
|
├── static/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── miuix.css # HyperOS 样式框架
|
||||||
|
│ │ └── main.css # 布局样式
|
||||||
|
│ └── js/ # 前端 JavaScript 模块
|
||||||
|
├── utils/logger.py # 日志系统
|
||||||
|
├── data/ # 运行时数据存储
|
||||||
|
├── logs/ # 日志文件
|
||||||
|
├── requirements.txt
|
||||||
|
└── run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 仪表盘配置说明
|
||||||
|
|
||||||
|
每个仪表盘主题的 `config.json` 支持以下渲染配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aspect_ratio": "auto", // auto | 16:9 | 4:3 | 1:1 | 21:9
|
||||||
|
"render_mode": "contain", // contain(留黑边) | cover(裁切) | fill(拉伸) | center(居中)
|
||||||
|
"max_width": 1920, // center 模式最大宽度
|
||||||
|
"max_height": 1080 // center 模式最大高度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 作者
|
||||||
|
|
||||||
|
**Yei.J. (AskaEth)**
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
Apache License 2.0
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
|
||||||
|
from config.settings import get_config
|
||||||
|
from server.api import router as api_router
|
||||||
|
from server.websocket import ws_manager
|
||||||
|
from server.telemetry.listener import telemetry_listener
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = BASE_DIR / "static"
|
||||||
|
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||||
|
|
||||||
|
os.makedirs(BASE_DIR / "logs", exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("=" * 50)
|
||||||
|
logger.info(" TurboSu - Racing Telemetry Dashboard")
|
||||||
|
logger.info("=" * 50)
|
||||||
|
cfg = get_config()
|
||||||
|
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
|
||||||
|
await telemetry_listener.start()
|
||||||
|
logger.info("Telemetry listener auto-started")
|
||||||
|
yield
|
||||||
|
telemetry_listener.stop()
|
||||||
|
logger.info("TurboSu shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="TurboSu", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard/{theme_id}", response_class=HTMLResponse)
|
||||||
|
async def dashboard_render(request: Request, theme_id: str):
|
||||||
|
from models.dashboard import dashboard_manager
|
||||||
|
theme = dashboard_manager.get(theme_id)
|
||||||
|
template_html = dashboard_manager.get_template(theme_id)
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"theme": theme,
|
||||||
|
"template_html": template_html,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scene/{scene_id}", response_class=HTMLResponse)
|
||||||
|
async def scene_render(request: Request, scene_id: str):
|
||||||
|
from models.scene import scene_manager
|
||||||
|
scene = scene_manager.get(scene_id)
|
||||||
|
return templates.TemplateResponse("scene.html", {
|
||||||
|
"request": request,
|
||||||
|
"scene": scene,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws")
|
||||||
|
async def websocket_endpoint(ws: WebSocket):
|
||||||
|
cid = await ws_manager.connect(ws)
|
||||||
|
try:
|
||||||
|
await ws.send_json({"type": "connected", "client_id": cid})
|
||||||
|
while True:
|
||||||
|
msg = await ws.receive_json()
|
||||||
|
if msg.get("type") == "ping":
|
||||||
|
await ws.send_json({"type": "pong"})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("WS error: %s", e)
|
||||||
|
finally:
|
||||||
|
ws_manager.disconnect(cid)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
cfg = get_config()
|
||||||
|
uvicorn.run(
|
||||||
|
"app:app",
|
||||||
|
host=cfg.get("server_host", "0.0.0.0"),
|
||||||
|
port=cfg.get("server_port", 9527),
|
||||||
|
reload=False,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
CONFIG_FILE = DATA_DIR / "config.json"
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
|
"selected_game_id": None,
|
||||||
|
"theme": "dark",
|
||||||
|
"sidebar_collapsed": False,
|
||||||
|
"telemetry_port": 20777,
|
||||||
|
"telemetry_host": "0.0.0.0",
|
||||||
|
"server_host": "0.0.0.0",
|
||||||
|
"server_port": 9527,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict[str, Any]:
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
merged = {**DEFAULT_CONFIG, **cfg}
|
||||||
|
logger.info("Config loaded from %s", CONFIG_FILE)
|
||||||
|
return merged
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load config: %s", e)
|
||||||
|
logger.info("No config file found, using defaults")
|
||||||
|
return DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict[str, Any]) -> None:
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||||
|
logger.info("Config saved to %s", CONFIG_FILE)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save config: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
_config_cache: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> dict[str, Any]:
|
||||||
|
global _config_cache
|
||||||
|
if _config_cache is None:
|
||||||
|
_config_cache = load_config()
|
||||||
|
return _config_cache
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(key: str, value: Any) -> None:
|
||||||
|
cfg = get_config()
|
||||||
|
cfg[key] = value
|
||||||
|
save_config(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config() -> None:
|
||||||
|
global _config_cache
|
||||||
|
_config_cache = None
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "gear_indicator",
|
||||||
|
"name": "档位指示器",
|
||||||
|
"category": "basic",
|
||||||
|
"description": "醒目大圆档位显示,适合竖屏手机副屏",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"config": {
|
||||||
|
"icon": "⚙️",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:20px;">
|
||||||
|
<div style="width:100px;height:100px;border-radius:50%;background:rgba(102,126,234,0.15);border:3px solid rgba(102,126,234,0.5);display:flex;align-items:center;justify-content:center;box-shadow:0 0 30px rgba(102,126,234,0.2);">
|
||||||
|
<span style="font-size:56px;font-weight:900;color:#fff;" data-bind="gear">N</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">R</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">1</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">2</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">3</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">4</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">5</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">6</span>
|
||||||
|
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "lap_timer",
|
||||||
|
"name": "圈速计时器",
|
||||||
|
"category": "timing",
|
||||||
|
"description": "当前圈速、最佳圈和上圈时间对比显示",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"config": {
|
||||||
|
"icon": "⏱️",
|
||||||
|
"aspect_ratio": "4:3",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:16px;">
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:12px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">LAP</div>
|
||||||
|
<div style="font-size:14px;color:rgba(255,255,255,0.3);" data-bind="lap_number">0</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">CURRENT</div>
|
||||||
|
<div style="font-size:48px;font-weight:900;color:#667eea;font-variant-numeric:tabular-nums;" data-bind="lap_time">00:00.000</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:40px;">
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">LAST</div>
|
||||||
|
<div style="font-size:24px;font-weight:700;color:rgba(255,255,255,0.6);font-variant-numeric:tabular-nums;" data-bind="last_lap">00:00.000</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">BEST</div>
|
||||||
|
<div style="font-size:24px;font-weight:700;color:#2ecc71;font-variant-numeric:tabular-nums;" data-bind="best_lap">00:00.000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "speedometer",
|
||||||
|
"name": "极速仪表 - 速度表",
|
||||||
|
"category": "speed",
|
||||||
|
"description": "大字号实时速度显示,支持km/h和mph,适合横屏全屏显示",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"config": {
|
||||||
|
"icon": "🏎️",
|
||||||
|
"unit": "kmh",
|
||||||
|
"show_unit": true,
|
||||||
|
"aspect_ratio": "auto",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14 0%,#1a1a30 100%);">
|
||||||
|
<div style="font-size:120px;font-weight:900;line-height:1;color:#fff;text-shadow:0 0 40px rgba(102,126,234,0.5);" data-bind="speed_kmh">0</div>
|
||||||
|
<div style="font-size:24px;color:rgba(255,255,255,0.6);margin-top:8px;letter-spacing:4px;">KM/H</div>
|
||||||
|
<div style="margin-top:20px;width:300px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;overflow:hidden;">
|
||||||
|
<div data-bind-speed style="height:100%;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:3px;transition:width 0.1s ease;width:calc(var(--speed,0) / 400 * 100%);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"id": "tachometer",
|
||||||
|
"name": "转速仪表 - 转速表",
|
||||||
|
"category": "rpm",
|
||||||
|
"description": "赛车风格弧形转速表,支持红区换档提示,推荐横屏",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"config": {
|
||||||
|
"icon": "⏱️",
|
||||||
|
"max_rpm": 8000,
|
||||||
|
"redline": 7000,
|
||||||
|
"aspect_ratio": "16:9",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);">
|
||||||
|
<svg viewBox="0 0 300 200" width="90%" style="max-width:500px;">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rpmGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stop-color="#667eea"/>
|
||||||
|
<stop offset="40%" stop-color="#667eea"/>
|
||||||
|
<stop offset="70%" stop-color="#f39c12"/>
|
||||||
|
<stop offset="100%" stop-color="#e74c3c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
<path id="rpm-arc" d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="url(#rpmGrad)" stroke-width="18" stroke-linecap="round"
|
||||||
|
stroke-dasharray="314" stroke-dashoffset="314" style="transition:stroke-dashoffset 0.1s ease;"/>
|
||||||
|
<text x="150" y="150" text-anchor="middle" fill="#fff" font-size="48" font-weight="900" data-bind="rpm">0</text>
|
||||||
|
<text x="150" y="180" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="14">RPM</text>
|
||||||
|
<text x="42" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">0</text>
|
||||||
|
<text x="258" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">8k</text>
|
||||||
|
</svg>
|
||||||
|
<div data-bind-rpm style="display:none;">
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const arc = document.getElementById('rpm-arc');
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const rpm = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm')) || 0;
|
||||||
|
const max = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm-max')) || 8000;
|
||||||
|
if (!isNaN(rpm) && arc) {
|
||||||
|
const pct = Math.min(rpm / max, 1);
|
||||||
|
arc.setAttribute('stroke-dashoffset', 314 - (314 * pct));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.querySelector('[data-bind-rpm]'), { attributes: true, attributeFilter: ['style'] });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "assetto_corsa",
|
||||||
|
"name": "Assetto Corsa",
|
||||||
|
"parser_type": "ac",
|
||||||
|
"telemetry_format": "ac",
|
||||||
|
"description": "神力科莎 UDP 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 9996
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return AssettoCorsaParser()
|
||||||
|
|
||||||
|
|
||||||
|
class AssettoCorsaParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "assetto_corsa"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="assetto_corsa", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||||
|
parts = text.split("\t")
|
||||||
|
if len(parts) < 10:
|
||||||
|
return td
|
||||||
|
td.speed_kmh = float(parts[0])
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = float(parts[1])
|
||||||
|
td.gear = int(float(parts[2]))
|
||||||
|
td.throttle = float(parts[3])
|
||||||
|
td.brake = float(parts[4])
|
||||||
|
td.steering = float(parts[5])
|
||||||
|
td.fuel = float(parts[6])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "assetto_corsa_competizione",
|
||||||
|
"name": "Assetto Corsa Competizione",
|
||||||
|
"parser_type": "acc",
|
||||||
|
"telemetry_format": "acc",
|
||||||
|
"description": "神力科莎:竞速 UDP 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return ACCParser()
|
||||||
|
|
||||||
|
|
||||||
|
class ACCParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "assetto_corsa_competizione"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="assetto_corsa_competizione", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 200:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.gear = struct.unpack_from("<B", data, 12)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 24)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 28)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "f1_24",
|
||||||
|
"name": "F1 24",
|
||||||
|
"parser_type": "f1",
|
||||||
|
"telemetry_format": "f124",
|
||||||
|
"description": "F1 24 UDP 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return F124Parser()
|
||||||
|
|
||||||
|
|
||||||
|
class F124Parser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "f1_24"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="f1_24", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 1289:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = struct.unpack_from("<H", data, 41)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<H", data, 43)[0]
|
||||||
|
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 47)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 55)[0]
|
||||||
|
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
|
||||||
|
td.lap_number = struct.unpack_from("<B", data, 262)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 63)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 51)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "forza_horizon_4",
|
||||||
|
"name": "Forza Horizon 4",
|
||||||
|
"parser_type": "forza",
|
||||||
|
"telemetry_format": "fh4",
|
||||||
|
"description": "极限竞速:地平线4 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return ForzaHorizon4Parser()
|
||||||
|
|
||||||
|
|
||||||
|
class ForzaHorizon4Parser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "forza_horizon_4"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="forza_horizon_4", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 323:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
offset = 12
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.horsepower = struct.unpack_from("<f", data, 12)[0]
|
||||||
|
td.torque = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
td.boost = struct.unpack_from("<f", data, 308)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 312)[0]
|
||||||
|
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
|
||||||
|
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.gear = struct.unpack_from("<B", data, 264)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.last_lap = struct.unpack_from("<f", data, 276)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 284)[0]
|
||||||
|
td.lap_number = struct.unpack_from("<H", data, 292)[0]
|
||||||
|
td.position_x = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.position_y = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.position_z = struct.unpack_from("<f", data, 552)[0]
|
||||||
|
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
|
||||||
|
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
|
||||||
|
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 228)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 232)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 204)[0]
|
||||||
|
td.clutch = struct.unpack_from("<f", data, 252)[0]
|
||||||
|
td.handbrake = struct.unpack_from("<f", data, 256)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "forza_horizon_5",
|
||||||
|
"name": "Forza Horizon 5",
|
||||||
|
"parser_type": "forza",
|
||||||
|
"telemetry_format": "fh5",
|
||||||
|
"description": "极限竞速:地平线5 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return ForzaHorizon5Parser()
|
||||||
|
|
||||||
|
|
||||||
|
class ForzaHorizon5Parser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "forza_horizon_5"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="forza_horizon_5", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 323:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.horsepower = struct.unpack_from("<f", data, 12)[0]
|
||||||
|
td.torque = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
td.boost = struct.unpack_from("<f", data, 308)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 312)[0]
|
||||||
|
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
|
||||||
|
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.gear = struct.unpack_from("<B", data, 264)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.last_lap = struct.unpack_from("<f", data, 276)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 284)[0]
|
||||||
|
td.lap_number = struct.unpack_from("<H", data, 292)[0]
|
||||||
|
td.position_x = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.position_y = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.position_z = struct.unpack_from("<f", data, 552)[0]
|
||||||
|
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
|
||||||
|
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
|
||||||
|
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 228)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 232)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 204)[0]
|
||||||
|
td.clutch = struct.unpack_from("<f", data, 252)[0]
|
||||||
|
td.handbrake = struct.unpack_from("<f", data, 256)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "forza_motorsport",
|
||||||
|
"name": "Forza Motorsport (2023)",
|
||||||
|
"parser_type": "forza",
|
||||||
|
"telemetry_format": "fm8",
|
||||||
|
"description": "极限竞速 Motorsport 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return ForzaMotorsportParser()
|
||||||
|
|
||||||
|
|
||||||
|
class ForzaMotorsportParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "forza_motorsport"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="forza_motorsport", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 323:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.horsepower = struct.unpack_from("<f", data, 12)[0]
|
||||||
|
td.torque = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
td.boost = struct.unpack_from("<f", data, 308)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 312)[0]
|
||||||
|
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
|
||||||
|
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 244)[0]
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.gear = struct.unpack_from("<B", data, 264)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.last_lap = struct.unpack_from("<f", data, 276)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 284)[0]
|
||||||
|
td.lap_number = struct.unpack_from("<H", data, 292)[0]
|
||||||
|
td.position_x = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.position_y = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.position_z = struct.unpack_from("<f", data, 552)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 228)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 232)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 204)[0]
|
||||||
|
td.clutch = struct.unpack_from("<f", data, 252)[0]
|
||||||
|
td.handbrake = struct.unpack_from("<f", data, 256)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "iracing",
|
||||||
|
"name": "iRacing",
|
||||||
|
"parser_type": "iracing",
|
||||||
|
"telemetry_format": "ir",
|
||||||
|
"description": "iRacing 遥测数据输出",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"default_port": 20777
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
def get_parser():
|
||||||
|
return IRacingParser()
|
||||||
|
|
||||||
|
|
||||||
|
class IRacingParser:
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "iracing"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="iracing", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 100:
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 48)[0]
|
||||||
|
td.gear = struct.unpack_from("<i", data, 56)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return td
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
DASHBOARDS_DIR = DATA_DIR / "dashboards"
|
||||||
|
BUILTIN_DASHBOARDS_DIR = Path(__file__).resolve().parent.parent / "dashboards"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardTheme:
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = ""
|
||||||
|
category: str = "basic"
|
||||||
|
description: str = ""
|
||||||
|
author: str = ""
|
||||||
|
version: str = "1.0.0"
|
||||||
|
preview: str = ""
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
is_builtin: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict[str, Any]) -> DashboardTheme:
|
||||||
|
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardManager:
|
||||||
|
def __init__(self):
|
||||||
|
DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._cache: dict[str, DashboardTheme] = {}
|
||||||
|
self._load_builtins()
|
||||||
|
|
||||||
|
def _load_builtins(self):
|
||||||
|
if not BUILTIN_DASHBOARDS_DIR.exists():
|
||||||
|
return
|
||||||
|
for item in BUILTIN_DASHBOARDS_DIR.iterdir():
|
||||||
|
if item.is_dir():
|
||||||
|
cfg_file = item / "config.json"
|
||||||
|
if cfg_file.exists():
|
||||||
|
try:
|
||||||
|
with open(cfg_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
theme = DashboardTheme.from_dict(data)
|
||||||
|
theme.is_builtin = True
|
||||||
|
self._cache[theme.id] = theme
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load builtin dashboard %s: %s", item.name, e)
|
||||||
|
|
||||||
|
def _load_user_dashboards(self):
|
||||||
|
for f in DASHBOARDS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(f, "r", encoding="utf-8") as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
theme = DashboardTheme.from_dict(data)
|
||||||
|
self._cache[theme.id] = theme
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load dashboard %s: %s", f.name, e)
|
||||||
|
|
||||||
|
def list_all(self, category: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
self._cache.clear()
|
||||||
|
self._load_builtins()
|
||||||
|
self._load_user_dashboards()
|
||||||
|
result = [t.to_dict() for t in self._cache.values()]
|
||||||
|
if category and category != "all":
|
||||||
|
result = [r for r in result if r.get("category") == category]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get(self, theme_id: str) -> dict[str, Any] | None:
|
||||||
|
self._cache.clear()
|
||||||
|
self._load_builtins()
|
||||||
|
self._load_user_dashboards()
|
||||||
|
theme = self._cache.get(theme_id)
|
||||||
|
return theme.to_dict() if theme else None
|
||||||
|
|
||||||
|
def save(self, theme: DashboardTheme) -> bool:
|
||||||
|
theme.updated_at = datetime.now().isoformat()
|
||||||
|
theme.is_builtin = False
|
||||||
|
filepath = DASHBOARDS_DIR / f"{theme.id}.json"
|
||||||
|
try:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(theme.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
|
logger.info("Dashboard saved: %s", theme.id)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save dashboard %s: %s", theme.id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self, theme_id: str) -> bool:
|
||||||
|
self._cache.clear()
|
||||||
|
self._load_builtins()
|
||||||
|
self._load_user_dashboards()
|
||||||
|
theme = self._cache.get(theme_id)
|
||||||
|
if theme and theme.is_builtin:
|
||||||
|
logger.warning("Cannot delete builtin dashboard: %s", theme_id)
|
||||||
|
return False
|
||||||
|
filepath = DASHBOARDS_DIR / f"{theme_id}.json"
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
logger.info("Dashboard deleted: %s", theme_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def export_theme(self, theme_id: str) -> dict[str, Any] | None:
|
||||||
|
theme = self.get(theme_id)
|
||||||
|
if not theme:
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
"type": "dashboard_theme",
|
||||||
|
"version": "1.0",
|
||||||
|
"data": theme,
|
||||||
|
"html": self._read_template_file(theme["id"]),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
def import_theme(self, data: dict[str, Any]) -> bool:
|
||||||
|
if data.get("type") != "dashboard_theme":
|
||||||
|
return False
|
||||||
|
theme_data = data.get("data", {})
|
||||||
|
theme = DashboardTheme.from_dict(theme_data)
|
||||||
|
html_content = data.get("html", "")
|
||||||
|
if theme.id in self._cache:
|
||||||
|
theme.id = str(uuid.uuid4())
|
||||||
|
success = self.save(theme)
|
||||||
|
if success and html_content:
|
||||||
|
self._save_template_file(theme.id, html_content)
|
||||||
|
return success
|
||||||
|
|
||||||
|
def _read_template_file(self, theme_id: str) -> str:
|
||||||
|
for base in [DASHBOARDS_DIR, BUILTIN_DASHBOARDS_DIR]:
|
||||||
|
tmpl = base / theme_id / "template.html"
|
||||||
|
if tmpl.exists():
|
||||||
|
return tmpl.read_text(encoding="utf-8")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _save_template_file(self, theme_id: str, content: str):
|
||||||
|
theme_dir = DASHBOARDS_DIR / theme_id
|
||||||
|
theme_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmpl = theme_dir / "template.html"
|
||||||
|
tmpl.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
def get_template(self, theme_id: str) -> str:
|
||||||
|
return self._read_template_file(theme_id)
|
||||||
|
|
||||||
|
def get_categories(self) -> list[str]:
|
||||||
|
self._cache.clear()
|
||||||
|
self._load_builtins()
|
||||||
|
self._load_user_dashboards()
|
||||||
|
cats = set()
|
||||||
|
for t in self._cache.values():
|
||||||
|
if t.category:
|
||||||
|
cats.add(t.category)
|
||||||
|
return sorted(cats)
|
||||||
|
|
||||||
|
|
||||||
|
dashboard_manager = DashboardManager()
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
SCENES_DIR = DATA_DIR / "scenes"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardPlacement:
|
||||||
|
dashboard_id: str = ""
|
||||||
|
x: float = 0.0
|
||||||
|
y: float = 0.0
|
||||||
|
width: int = 400
|
||||||
|
height: int = 300
|
||||||
|
z_index: int = 0
|
||||||
|
scale_x: float = 1.0
|
||||||
|
scale_y: float = 1.0
|
||||||
|
aspect_ratio: str = "auto"
|
||||||
|
render_mode: str = "contain"
|
||||||
|
custom_config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SceneCanvas:
|
||||||
|
width: int = 1920
|
||||||
|
height: int = 1080
|
||||||
|
label: str = "16:9"
|
||||||
|
placements: list[DashboardPlacement] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Scene:
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
name: str = "New Scene"
|
||||||
|
game_id: str = ""
|
||||||
|
description: str = ""
|
||||||
|
canvases: list[SceneCanvas] = field(default_factory=list)
|
||||||
|
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
d = asdict(self)
|
||||||
|
d["canvases"] = [asdict(c) for c in self.canvases]
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict[str, Any]) -> Scene:
|
||||||
|
canvases = []
|
||||||
|
for cd in d.get("canvases", []):
|
||||||
|
placements = [DashboardPlacement(**p) for p in cd.get("placements", [])]
|
||||||
|
canvases.append(SceneCanvas(
|
||||||
|
width=cd.get("width", 1920),
|
||||||
|
height=cd.get("height", 1080),
|
||||||
|
label=cd.get("label", ""),
|
||||||
|
placements=placements,
|
||||||
|
))
|
||||||
|
return cls(
|
||||||
|
id=d.get("id", str(uuid.uuid4())),
|
||||||
|
name=d.get("name", "New Scene"),
|
||||||
|
game_id=d.get("game_id", ""),
|
||||||
|
description=d.get("description", ""),
|
||||||
|
canvases=canvases,
|
||||||
|
created_at=d.get("created_at", datetime.now().isoformat()),
|
||||||
|
updated_at=d.get("updated_at", datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SceneManager:
|
||||||
|
def __init__(self):
|
||||||
|
SCENES_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
scenes = []
|
||||||
|
for f in SCENES_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(f, "r", encoding="utf-8") as fp:
|
||||||
|
data = json.load(fp)
|
||||||
|
scene = Scene.from_dict(data)
|
||||||
|
if game_id is None or scene.game_id == game_id:
|
||||||
|
scenes.append(scene.to_dict())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load scene %s: %s", f.name, e)
|
||||||
|
return scenes
|
||||||
|
|
||||||
|
def get(self, scene_id: str) -> dict[str, Any] | None:
|
||||||
|
filepath = SCENES_DIR / f"{scene_id}.json"
|
||||||
|
if not filepath.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return Scene.from_dict(data).to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to read scene %s: %s", scene_id, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self, scene: Scene) -> bool:
|
||||||
|
scene.updated_at = datetime.now().isoformat()
|
||||||
|
filepath = SCENES_DIR / f"{scene.id}.json"
|
||||||
|
try:
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
|
logger.info("Scene saved: %s", scene.id)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save scene %s: %s", scene.id, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self, scene_id: str) -> bool:
|
||||||
|
filepath = SCENES_DIR / f"{scene_id}.json"
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
logger.info("Scene deleted: %s", scene_id)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_scene(self, scene_id: str) -> dict[str, Any] | None:
|
||||||
|
scene = self.get(scene_id)
|
||||||
|
if not scene:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"type": "scene",
|
||||||
|
"version": "1.0",
|
||||||
|
"data": scene,
|
||||||
|
}
|
||||||
|
|
||||||
|
def import_scene(self, data: dict[str, Any]) -> bool:
|
||||||
|
if data.get("type") != "scene":
|
||||||
|
return False
|
||||||
|
scene_data = data.get("data", {})
|
||||||
|
if "id" not in scene_data:
|
||||||
|
scene_data["id"] = str(uuid.uuid4())
|
||||||
|
scene = Scene.from_dict(scene_data)
|
||||||
|
return self.save(scene)
|
||||||
|
|
||||||
|
|
||||||
|
scene_manager = SceneManager()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
websockets==14.1
|
||||||
|
aiofiles==24.1.0
|
||||||
|
jinja2==3.1.4
|
||||||
|
python-multipart==0.0.18
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "[TurboSu] Creating virtual environment..."
|
||||||
|
python3 -m venv venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
echo "[TurboSu] Installing dependencies..."
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
|
||||||
|
echo "[TurboSu] Starting server..."
|
||||||
|
python app.py
|
||||||
+279
@@ -0,0 +1,279 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from config.settings import get_config, update_config
|
||||||
|
from models.dashboard import dashboard_manager, DashboardTheme
|
||||||
|
from models.scene import scene_manager, Scene, SceneCanvas, DashboardPlacement
|
||||||
|
from server.telemetry.listener import telemetry_listener
|
||||||
|
from server.websocket import ws_manager
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Status ----
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_status():
|
||||||
|
latest = telemetry_listener.latest_data
|
||||||
|
cfg = get_config()
|
||||||
|
return {
|
||||||
|
"server_running": True,
|
||||||
|
"telemetry_running": telemetry_listener.is_running,
|
||||||
|
"ws_clients": ws_manager.client_count,
|
||||||
|
"packet_count": telemetry_listener.packet_count,
|
||||||
|
"last_packet_time": telemetry_listener.last_packet_time,
|
||||||
|
"selected_game_id": cfg.get("selected_game_id"),
|
||||||
|
"latest_data": latest.to_dict() if latest else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Config ----
|
||||||
|
@router.get("/config")
|
||||||
|
async def api_get_config():
|
||||||
|
return get_config()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def api_update_config(data: dict[str, Any]):
|
||||||
|
cfg = get_config()
|
||||||
|
for k, v in data.items():
|
||||||
|
cfg[k] = v
|
||||||
|
from config.settings import save_config
|
||||||
|
save_config(cfg)
|
||||||
|
|
||||||
|
if "selected_game_id" in data:
|
||||||
|
telemetry_listener.set_parser_for_game(data["selected_game_id"])
|
||||||
|
|
||||||
|
if "theme" in data:
|
||||||
|
cfg["theme"] = data["theme"]
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Telemetry ----
|
||||||
|
@router.post("/telemetry/start")
|
||||||
|
async def api_start_telemetry():
|
||||||
|
ok = await telemetry_listener.start()
|
||||||
|
return {"ok": ok, "running": telemetry_listener.is_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/telemetry/stop")
|
||||||
|
async def api_stop_telemetry():
|
||||||
|
telemetry_listener.stop()
|
||||||
|
return {"ok": True, "running": telemetry_listener.is_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/telemetry/latest")
|
||||||
|
async def api_latest_telemetry():
|
||||||
|
latest = telemetry_listener.latest_data
|
||||||
|
if latest:
|
||||||
|
return {
|
||||||
|
"data": latest.to_dict(),
|
||||||
|
"raw": latest.raw,
|
||||||
|
}
|
||||||
|
return {"data": None, "raw": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Dashboards ----
|
||||||
|
@router.get("/dashboards")
|
||||||
|
async def api_list_dashboards(category: str = "all"):
|
||||||
|
return dashboard_manager.list_all(category)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboards/categories")
|
||||||
|
async def api_dashboard_categories():
|
||||||
|
return dashboard_manager.get_categories()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboards/{theme_id}")
|
||||||
|
async def api_get_dashboard(theme_id: str):
|
||||||
|
theme = dashboard_manager.get(theme_id)
|
||||||
|
if not theme:
|
||||||
|
raise HTTPException(404, "Dashboard not found")
|
||||||
|
return theme
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboards/{theme_id}/template")
|
||||||
|
async def api_get_dashboard_template(theme_id: str):
|
||||||
|
tmpl = dashboard_manager.get_template(theme_id)
|
||||||
|
return {"html": tmpl}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboards")
|
||||||
|
async def api_create_dashboard(data: dict[str, Any]):
|
||||||
|
theme = DashboardTheme(
|
||||||
|
name=data.get("name", "Untitled"),
|
||||||
|
category=data.get("category", "basic"),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
author=data.get("author", ""),
|
||||||
|
config=data.get("config", {}),
|
||||||
|
)
|
||||||
|
dashboard_manager.save(theme)
|
||||||
|
if data.get("template_html"):
|
||||||
|
dashboard_manager._save_template_file(theme.id, data["template_html"])
|
||||||
|
return theme.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/dashboards/{theme_id}")
|
||||||
|
async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
|
||||||
|
existing = dashboard_manager.get(theme_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Dashboard not found")
|
||||||
|
theme = DashboardTheme.from_dict(existing)
|
||||||
|
for k in ["name", "category", "description", "author", "config"]:
|
||||||
|
if k in data:
|
||||||
|
setattr(theme, k, data[k])
|
||||||
|
dashboard_manager.save(theme)
|
||||||
|
if "template_html" in data:
|
||||||
|
dashboard_manager._save_template_file(theme.id, data["template_html"])
|
||||||
|
return theme.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dashboards/{theme_id}")
|
||||||
|
async def api_delete_dashboard(theme_id: str):
|
||||||
|
ok = dashboard_manager.delete(theme_id)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboards/{theme_id}/export")
|
||||||
|
async def api_export_dashboard(theme_id: str):
|
||||||
|
result = dashboard_manager.export_theme(theme_id)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "Dashboard not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dashboards/import")
|
||||||
|
async def api_import_dashboard(data: dict[str, Any]):
|
||||||
|
ok = dashboard_manager.import_theme(data)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Scenes ----
|
||||||
|
@router.get("/scenes")
|
||||||
|
async def api_list_scenes(game_id: str = ""):
|
||||||
|
return scene_manager.list_all(game_id or None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scenes/{scene_id}")
|
||||||
|
async def api_get_scene(scene_id: str):
|
||||||
|
scene = scene_manager.get(scene_id)
|
||||||
|
if not scene:
|
||||||
|
raise HTTPException(404, "Scene not found")
|
||||||
|
return scene
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scenes")
|
||||||
|
async def api_create_scene(data: dict[str, Any]):
|
||||||
|
scene = Scene(
|
||||||
|
name=data.get("name", "New Scene"),
|
||||||
|
game_id=data.get("game_id", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
canvases=[SceneCanvas(label="16:9", width=1920, height=1080)],
|
||||||
|
)
|
||||||
|
if data.get("canvases"):
|
||||||
|
scene.canvases = [
|
||||||
|
SceneCanvas(
|
||||||
|
width=c.get("width", 1920),
|
||||||
|
height=c.get("height", 1080),
|
||||||
|
label=c.get("label", ""),
|
||||||
|
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
|
||||||
|
)
|
||||||
|
for c in data["canvases"]
|
||||||
|
]
|
||||||
|
scene_manager.save(scene)
|
||||||
|
return scene.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/scenes/{scene_id}")
|
||||||
|
async def api_update_scene(scene_id: str, data: dict[str, Any]):
|
||||||
|
existing = scene_manager.get(scene_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(404, "Scene not found")
|
||||||
|
scene = Scene.from_dict(existing)
|
||||||
|
for k in ["name", "game_id", "description"]:
|
||||||
|
if k in data:
|
||||||
|
setattr(scene, k, data[k])
|
||||||
|
if "canvases" in data:
|
||||||
|
scene.canvases = [
|
||||||
|
SceneCanvas(
|
||||||
|
width=c.get("width", 1920),
|
||||||
|
height=c.get("height", 1080),
|
||||||
|
label=c.get("label", "Custom"),
|
||||||
|
placements=[DashboardPlacement(**p) for p in c.get("placements", [])],
|
||||||
|
)
|
||||||
|
for c in data["canvases"]
|
||||||
|
]
|
||||||
|
scene_manager.save(scene)
|
||||||
|
return scene.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/scenes/{scene_id}")
|
||||||
|
async def api_delete_scene(scene_id: str):
|
||||||
|
ok = scene_manager.delete(scene_id)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scenes/{scene_id}/export")
|
||||||
|
async def api_export_scene(scene_id: str):
|
||||||
|
result = scene_manager.export_scene(scene_id)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "Scene not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scenes/import")
|
||||||
|
async def api_import_scene(data: dict[str, Any]):
|
||||||
|
ok = scene_manager.import_scene(data)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Game Plugins ----
|
||||||
|
from server.game_manager import game_plugin_manager
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/games")
|
||||||
|
async def api_list_games():
|
||||||
|
return game_plugin_manager.list_all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/games/{plugin_id}")
|
||||||
|
async def api_get_game(plugin_id: str):
|
||||||
|
gp = game_plugin_manager.get(plugin_id)
|
||||||
|
if not gp:
|
||||||
|
raise HTTPException(404, "Game plugin not found")
|
||||||
|
return gp.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/install")
|
||||||
|
async def api_install_game_plugin(data: dict[str, Any]):
|
||||||
|
ok = game_plugin_manager.install_plugin(data)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/games/{plugin_id}")
|
||||||
|
async def api_remove_game_plugin(plugin_id: str):
|
||||||
|
ok = game_plugin_manager.remove_plugin(plugin_id)
|
||||||
|
return {"ok": ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/games/{plugin_id}/export")
|
||||||
|
async def api_export_game_plugin(plugin_id: str):
|
||||||
|
result = game_plugin_manager.export_plugin(plugin_id)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(404, "Game plugin not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/games/reload")
|
||||||
|
async def api_reload_game_plugins():
|
||||||
|
game_plugin_manager.reload()
|
||||||
|
return {"ok": True, "count": len(game_plugin_manager.list_all())}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
GAMES_DIR = Path(__file__).resolve().parent.parent / "games"
|
||||||
|
BUILTIN_DIR = GAMES_DIR / "builtin"
|
||||||
|
USER_DIR = GAMES_DIR / "user"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GamePlugin:
|
||||||
|
id: str = ""
|
||||||
|
name: str = ""
|
||||||
|
parser_type: str = "forza"
|
||||||
|
telemetry_format: str = ""
|
||||||
|
description: str = ""
|
||||||
|
author: str = ""
|
||||||
|
version: str = "1.0.0"
|
||||||
|
default_port: int = 20777
|
||||||
|
icon: str = ""
|
||||||
|
is_builtin: bool = True
|
||||||
|
enabled: bool = True
|
||||||
|
manifest_path: str = ""
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_manifest(cls, path: Path, is_builtin: bool = True) -> GamePlugin | None:
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
gp = cls(
|
||||||
|
id=data.get("id", path.parent.name),
|
||||||
|
name=data.get("name", path.parent.name),
|
||||||
|
parser_type=data.get("parser_type", "forza"),
|
||||||
|
telemetry_format=data.get("telemetry_format", ""),
|
||||||
|
description=data.get("description", ""),
|
||||||
|
author=data.get("author", ""),
|
||||||
|
version=data.get("version", "1.0.0"),
|
||||||
|
default_port=data.get("default_port", 20777),
|
||||||
|
icon=data.get("icon", ""),
|
||||||
|
is_builtin=is_builtin,
|
||||||
|
enabled=True,
|
||||||
|
manifest_path=str(path.parent),
|
||||||
|
)
|
||||||
|
return gp
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load game plugin manifest %s: %s", path, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class GamePluginManager:
|
||||||
|
def __init__(self):
|
||||||
|
BUILTIN_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._plugins: dict[str, GamePlugin] = {}
|
||||||
|
self._parser_cache: dict[str, Any] = {}
|
||||||
|
self._discover()
|
||||||
|
|
||||||
|
def _discover(self):
|
||||||
|
self._plugins.clear()
|
||||||
|
|
||||||
|
for d in [BUILTIN_DIR, USER_DIR]:
|
||||||
|
if not d.exists():
|
||||||
|
continue
|
||||||
|
is_builtin = (d == BUILTIN_DIR)
|
||||||
|
for item in d.iterdir():
|
||||||
|
if item.is_dir():
|
||||||
|
manifest = item / "manifest.json"
|
||||||
|
if manifest.exists():
|
||||||
|
gp = GamePlugin.from_manifest(manifest, is_builtin)
|
||||||
|
if gp:
|
||||||
|
self._plugins[gp.id] = gp
|
||||||
|
logger.debug("Discovered game plugin: %s", gp.id)
|
||||||
|
|
||||||
|
def list_all(self) -> list[dict[str, Any]]:
|
||||||
|
self._discover()
|
||||||
|
return [p.to_dict() for p in self._plugins.values()]
|
||||||
|
|
||||||
|
def get(self, plugin_id: str) -> GamePlugin | None:
|
||||||
|
self._discover()
|
||||||
|
return self._plugins.get(plugin_id)
|
||||||
|
|
||||||
|
def get_parser(self, plugin_id: str) -> Any | None:
|
||||||
|
self._discover()
|
||||||
|
gp = self._plugins.get(plugin_id)
|
||||||
|
if not gp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = gp.id
|
||||||
|
if cache_key in self._parser_cache:
|
||||||
|
return self._parser_cache[cache_key]
|
||||||
|
|
||||||
|
parser_dir = Path(gp.manifest_path)
|
||||||
|
parser_file = parser_dir / "parser.py"
|
||||||
|
if not parser_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
module_name = f"turbosu_game_{gp.id}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, parser_file)
|
||||||
|
if spec and spec.loader:
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
if hasattr(module, "get_parser"):
|
||||||
|
parser = module.get_parser()
|
||||||
|
self._parser_cache[cache_key] = parser
|
||||||
|
logger.info("Loaded parser for game: %s", gp.name)
|
||||||
|
return parser
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to load parser for %s: %s", gp.id, e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
self._discover()
|
||||||
|
self._parser_cache.clear()
|
||||||
|
|
||||||
|
def install_plugin(self, data: dict[str, Any]) -> bool:
|
||||||
|
plugin_id = data.get("id", "")
|
||||||
|
if not plugin_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
dest_dir = USER_DIR / plugin_id
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
manifest = data.get("manifest", {})
|
||||||
|
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
if data.get("parser_code"):
|
||||||
|
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
|
||||||
|
f.write(data["parser_code"])
|
||||||
|
|
||||||
|
self._discover()
|
||||||
|
logger.info("Plugin installed: %s", plugin_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_plugin(self, plugin_id: str) -> bool:
|
||||||
|
gp = self._plugins.get(plugin_id)
|
||||||
|
if gp and gp.is_builtin:
|
||||||
|
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
dest_dir = USER_DIR / plugin_id
|
||||||
|
if dest_dir.exists():
|
||||||
|
shutil.rmtree(dest_dir)
|
||||||
|
self._discover()
|
||||||
|
logger.info("Plugin removed: %s", plugin_id)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
|
||||||
|
gp = self._plugins.get(plugin_id)
|
||||||
|
if not gp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parser_dir = Path(gp.manifest_path)
|
||||||
|
manifest_file = parser_dir / "manifest.json"
|
||||||
|
parser_file = parser_dir / "parser.py"
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"type": "game_plugin",
|
||||||
|
"version": "1.0",
|
||||||
|
"manifest": {},
|
||||||
|
"parser_code": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifest_file.exists():
|
||||||
|
with open(manifest_file, "r", encoding="utf-8") as f:
|
||||||
|
result["manifest"] = json.load(f)
|
||||||
|
if parser_file.exists():
|
||||||
|
with open(parser_file, "r", encoding="utf-8") as f:
|
||||||
|
result["parser_code"] = f.read()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
game_plugin_manager = GamePluginManager()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetryData:
|
||||||
|
game_id: str = ""
|
||||||
|
timestamp: float = 0.0
|
||||||
|
|
||||||
|
speed_kmh: float = 0.0
|
||||||
|
speed_mph: float = 0.0
|
||||||
|
|
||||||
|
rpm: float = 0.0
|
||||||
|
max_rpm: float = 8000.0
|
||||||
|
|
||||||
|
gear: int = 0
|
||||||
|
|
||||||
|
throttle: float = 0.0
|
||||||
|
brake: float = 0.0
|
||||||
|
clutch: float = 0.0
|
||||||
|
handbrake: float = 0.0
|
||||||
|
|
||||||
|
steering: float = 0.0
|
||||||
|
|
||||||
|
lap_time: float = 0.0
|
||||||
|
best_lap: float = 0.0
|
||||||
|
last_lap: float = 0.0
|
||||||
|
lap_number: int = 0
|
||||||
|
|
||||||
|
position_x: float = 0.0
|
||||||
|
position_y: float = 0.0
|
||||||
|
position_z: float = 0.0
|
||||||
|
|
||||||
|
acceleration_x: float = 0.0
|
||||||
|
acceleration_y: float = 0.0
|
||||||
|
acceleration_z: float = 0.0
|
||||||
|
|
||||||
|
engine_temp: float = 0.0
|
||||||
|
oil_temp: float = 0.0
|
||||||
|
fuel: float = 0.0
|
||||||
|
|
||||||
|
boost: float = 0.0
|
||||||
|
horsepower: float = 0.0
|
||||||
|
torque: float = 0.0
|
||||||
|
|
||||||
|
car_name: str = ""
|
||||||
|
car_class: str = ""
|
||||||
|
|
||||||
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"game_id": self.game_id,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
"speed_kmh": self.speed_kmh,
|
||||||
|
"speed_mph": self.speed_mph,
|
||||||
|
"rpm": self.rpm,
|
||||||
|
"max_rpm": self.max_rpm,
|
||||||
|
"gear": self.gear,
|
||||||
|
"throttle": self.throttle,
|
||||||
|
"brake": self.brake,
|
||||||
|
"clutch": self.clutch,
|
||||||
|
"handbrake": self.handbrake,
|
||||||
|
"steering": self.steering,
|
||||||
|
"lap_time": self.lap_time,
|
||||||
|
"best_lap": self.best_lap,
|
||||||
|
"last_lap": self.last_lap,
|
||||||
|
"lap_number": self.lap_number,
|
||||||
|
"position_x": self.position_x,
|
||||||
|
"position_y": self.position_y,
|
||||||
|
"position_z": self.position_z,
|
||||||
|
"acceleration_x": self.acceleration_x,
|
||||||
|
"acceleration_y": self.acceleration_y,
|
||||||
|
"acceleration_z": self.acceleration_z,
|
||||||
|
"engine_temp": self.engine_temp,
|
||||||
|
"oil_temp": self.oil_temp,
|
||||||
|
"fuel": self.fuel,
|
||||||
|
"boost": self.boost,
|
||||||
|
"horsepower": self.horsepower,
|
||||||
|
"torque": self.torque,
|
||||||
|
"car_name": self.car_name,
|
||||||
|
"car_class": self.car_class,
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from config.settings import get_config
|
||||||
|
from server.telemetry.parsers import PARSER_MAP, BaseParser
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
from server.game_manager import game_plugin_manager
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryListener:
|
||||||
|
def __init__(self):
|
||||||
|
self._transport: asyncio.DatagramTransport | None = None
|
||||||
|
self._running = False
|
||||||
|
self._callbacks: list[Callable[[TelemetryData], None]] = []
|
||||||
|
self._parser: BaseParser | None = None
|
||||||
|
self._parser_cache: dict[str, BaseParser] = {}
|
||||||
|
self._latest_data: TelemetryData | None = None
|
||||||
|
self._last_packet_time: float = 0.0
|
||||||
|
self._packet_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_data(self) -> TelemetryData | None:
|
||||||
|
return self._latest_data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def packet_count(self) -> int:
|
||||||
|
return self._packet_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_packet_time(self) -> float:
|
||||||
|
return self._last_packet_time
|
||||||
|
|
||||||
|
def on_data(self, callback: Callable[[TelemetryData], None]):
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_callback(self, callback: Callable[[TelemetryData], None]):
|
||||||
|
if callback in self._callbacks:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
async def start(self) -> bool:
|
||||||
|
if self._running:
|
||||||
|
return True
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
host = cfg.get("telemetry_host", "0.0.0.0")
|
||||||
|
port = cfg.get("telemetry_port", 20777)
|
||||||
|
|
||||||
|
selected_game = cfg.get("selected_game_id")
|
||||||
|
parser_key = None
|
||||||
|
if selected_game:
|
||||||
|
for game in cfg.get("games", []):
|
||||||
|
if game["id"] == selected_game:
|
||||||
|
parser_key = game.get("parser", "forza")
|
||||||
|
break
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
self._transport, _ = await loop.create_datagram_endpoint(
|
||||||
|
lambda: _TelemetryProtocol(self),
|
||||||
|
local_addr=(host, port),
|
||||||
|
)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
if parser_key and parser_key in PARSER_MAP:
|
||||||
|
self._parser = self._get_parser(parser_key)
|
||||||
|
logger.info("Telemetry listener started on %s:%d [parser=%s]", host, port, parser_key)
|
||||||
|
else:
|
||||||
|
self._parser = None
|
||||||
|
logger.info("Telemetry listener started on %s:%d [auto-detect]", host, port)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start telemetry listener: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._transport:
|
||||||
|
self._transport.close()
|
||||||
|
self._transport = None
|
||||||
|
logger.info("Telemetry listener stopped")
|
||||||
|
|
||||||
|
def _get_parser(self, key: str) -> BaseParser:
|
||||||
|
if key not in self._parser_cache:
|
||||||
|
cls = PARSER_MAP.get(key)
|
||||||
|
if cls:
|
||||||
|
self._parser_cache[key] = cls()
|
||||||
|
return self._parser_cache.get(key, PARSER_MAP["forza"]())
|
||||||
|
|
||||||
|
def _handle_packet(self, data: bytes, addr: tuple[str, int]):
|
||||||
|
self._last_packet_time = time.time()
|
||||||
|
self._packet_count += 1
|
||||||
|
|
||||||
|
td = None
|
||||||
|
if self._parser:
|
||||||
|
td = self._parser.parse(data, addr)
|
||||||
|
else:
|
||||||
|
for parser_cls in PARSER_MAP.values():
|
||||||
|
p = parser_cls()
|
||||||
|
td = p.parse(data, addr)
|
||||||
|
if td and td.speed_kmh > 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if td is None:
|
||||||
|
td = TelemetryData(timestamp=time.time(), raw={"raw_hex": data.hex(), "length": len(data)})
|
||||||
|
|
||||||
|
self._latest_data = td
|
||||||
|
for cb in self._callbacks:
|
||||||
|
try:
|
||||||
|
cb(td)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Callback error: %s", e)
|
||||||
|
|
||||||
|
def set_parser_for_game(self, game_id: str):
|
||||||
|
custom_parser = game_plugin_manager.get_parser(game_id)
|
||||||
|
if custom_parser:
|
||||||
|
self._parser = custom_parser
|
||||||
|
logger.info("Parser loaded from plugin for game: %s", game_id)
|
||||||
|
return
|
||||||
|
gp = game_plugin_manager.get(game_id)
|
||||||
|
if gp:
|
||||||
|
parser_key = gp.parser_type
|
||||||
|
if parser_key in PARSER_MAP:
|
||||||
|
self._parser = self._get_parser(parser_key)
|
||||||
|
logger.info("Parser set to %s for game %s", parser_key, game_id)
|
||||||
|
return
|
||||||
|
self._parser = None
|
||||||
|
|
||||||
|
|
||||||
|
class _TelemetryProtocol(asyncio.DatagramProtocol):
|
||||||
|
def __init__(self, listener: TelemetryListener):
|
||||||
|
self._listener = listener
|
||||||
|
|
||||||
|
def datagram_received(self, data: bytes, addr: tuple[str, int]):
|
||||||
|
self._listener._handle_packet(data, addr)
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
telemetry_listener = TelemetryListener()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
from server.telemetry.parsers.base import BaseParser
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ForzaParser(BaseParser):
|
||||||
|
FORZA_FORMATS = {
|
||||||
|
"fh4": "Forza Horizon 4",
|
||||||
|
"fh5": "Forza Horizon 5",
|
||||||
|
"fm8": "Forza Motorsport",
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, format_id: str = "fh5"):
|
||||||
|
self._format = format_id
|
||||||
|
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return self._format
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id=self._format, timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 323:
|
||||||
|
logger.warning("Forza data too short: %d bytes", len(data))
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._format == "fh4":
|
||||||
|
offset = 0
|
||||||
|
else:
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.horsepower = struct.unpack_from("<f", data, 12)[0]
|
||||||
|
td.torque = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
|
||||||
|
td.boost = struct.unpack_from("<f", data, 308)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 312)[0]
|
||||||
|
td.oil_temp = struct.unpack_from("<f", data, 316)[0]
|
||||||
|
td.engine_temp = struct.unpack_from("<f", data, 320)[0]
|
||||||
|
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 244)
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.gear = struct.unpack_from("<B", data, 264)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.last_lap = struct.unpack_from("<f", data, 276)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 284)[0]
|
||||||
|
td.lap_number = struct.unpack_from("<H", data, 292)[0]
|
||||||
|
|
||||||
|
td.position_x = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.position_y = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.position_z = struct.unpack_from("<f", data, 552)[0]
|
||||||
|
|
||||||
|
td.acceleration_x = struct.unpack_from("<f", data, 300)[0]
|
||||||
|
td.acceleration_y = struct.unpack_from("<f", data, 304)[0]
|
||||||
|
td.acceleration_z = struct.unpack_from("<f", data, 196)[0]
|
||||||
|
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 228)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 232)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 204)[0]
|
||||||
|
td.clutch = struct.unpack_from("<f", data, 252)[0]
|
||||||
|
td.handbrake = struct.unpack_from("<f", data, 256)[0]
|
||||||
|
|
||||||
|
td.raw.update({
|
||||||
|
"speed_mph": td.speed_mph,
|
||||||
|
"speed_kmh": td.speed_kmh,
|
||||||
|
"rpm": td.rpm,
|
||||||
|
"max_rpm": td.max_rpm,
|
||||||
|
"gear": td.gear,
|
||||||
|
"throttle": td.throttle,
|
||||||
|
"brake": td.brake,
|
||||||
|
"steering": td.steering,
|
||||||
|
"boost": td.boost,
|
||||||
|
"horsepower": td.horsepower,
|
||||||
|
"torque": td.torque,
|
||||||
|
})
|
||||||
|
|
||||||
|
except struct.error as e:
|
||||||
|
logger.error("Forza parse error: %s", e)
|
||||||
|
|
||||||
|
return td
|
||||||
|
|
||||||
|
|
||||||
|
class ACCParser(BaseParser):
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "acc"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="acc", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 200:
|
||||||
|
logger.warning("ACC data too short: %d bytes", len(data))
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_kmh = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.gear = struct.unpack_from("<B", data, 12)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 16)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 20)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 24)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 28)[0]
|
||||||
|
|
||||||
|
td.raw.update({
|
||||||
|
"speed_kmh": td.speed_kmh,
|
||||||
|
"rpm": td.rpm,
|
||||||
|
"gear": td.gear,
|
||||||
|
"throttle": td.throttle,
|
||||||
|
"brake": td.brake,
|
||||||
|
})
|
||||||
|
except struct.error as e:
|
||||||
|
logger.error("ACC parse error: %s", e)
|
||||||
|
|
||||||
|
return td
|
||||||
|
|
||||||
|
|
||||||
|
class F1Parser(BaseParser):
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "f1"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="f1", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 1289:
|
||||||
|
logger.warning("F1 data too short: %d bytes", len(data))
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = struct.unpack_from("<H", data, 41)[0]
|
||||||
|
td.max_rpm = struct.unpack_from("<H", data, 43)[0]
|
||||||
|
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 47)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 55)[0]
|
||||||
|
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
|
||||||
|
td.lap_number = struct.unpack_from("<B", data, 262)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, 63)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, 51)[0]
|
||||||
|
|
||||||
|
td.raw.update({
|
||||||
|
"speed_kmh": td.speed_kmh,
|
||||||
|
"rpm": td.rpm,
|
||||||
|
"gear": td.gear,
|
||||||
|
"throttle": td.throttle,
|
||||||
|
"brake": td.brake,
|
||||||
|
"lap_time": td.lap_time,
|
||||||
|
})
|
||||||
|
except struct.error as e:
|
||||||
|
logger.error("F1 parse error: %s", e)
|
||||||
|
|
||||||
|
return td
|
||||||
|
|
||||||
|
|
||||||
|
class IRacingParser(BaseParser):
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "iracing"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="iracing", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
if len(data) < 100:
|
||||||
|
logger.warning("iRacing data too short: %d bytes", len(data))
|
||||||
|
return td
|
||||||
|
|
||||||
|
try:
|
||||||
|
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
|
||||||
|
td.speed_kmh = td.speed_mph * 1.60934
|
||||||
|
td.rpm = struct.unpack_from("<f", data, 48)[0]
|
||||||
|
td.gear = struct.unpack_from("<i", data, 56)[0]
|
||||||
|
td.throttle = struct.unpack_from("<f", data, 4)[0]
|
||||||
|
td.brake = struct.unpack_from("<f", data, 8)[0]
|
||||||
|
td.steering = struct.unpack_from("<f", data, 0)[0]
|
||||||
|
|
||||||
|
td.raw.update({
|
||||||
|
"speed_mph": td.speed_mph,
|
||||||
|
"rpm": td.rpm,
|
||||||
|
"gear": td.gear,
|
||||||
|
"throttle": td.throttle,
|
||||||
|
"brake": td.brake,
|
||||||
|
})
|
||||||
|
except struct.error as e:
|
||||||
|
logger.error("iRacing parse error: %s", e)
|
||||||
|
|
||||||
|
return td
|
||||||
|
|
||||||
|
|
||||||
|
class ACParser(BaseParser):
|
||||||
|
def game_id(self) -> str:
|
||||||
|
return "ac"
|
||||||
|
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
td = TelemetryData(game_id="ac", timestamp=time.time())
|
||||||
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
parts = data.decode("utf-8", errors="replace").rstrip("\r\n").split("\t")
|
||||||
|
if len(parts) < 10:
|
||||||
|
return td
|
||||||
|
|
||||||
|
td.speed_kmh = float(parts[0])
|
||||||
|
td.speed_mph = td.speed_kmh * 0.621371
|
||||||
|
td.rpm = float(parts[1])
|
||||||
|
td.gear = int(float(parts[2]))
|
||||||
|
td.throttle = float(parts[3])
|
||||||
|
td.brake = float(parts[4])
|
||||||
|
td.steering = float(parts[5])
|
||||||
|
td.fuel = float(parts[6])
|
||||||
|
|
||||||
|
td.raw.update({
|
||||||
|
"speed_kmh": td.speed_kmh,
|
||||||
|
"rpm": td.rpm,
|
||||||
|
"gear": td.gear,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("AC parse error: %s", e)
|
||||||
|
|
||||||
|
return td
|
||||||
|
|
||||||
|
|
||||||
|
PARSER_MAP: dict[str, type[BaseParser]] = {
|
||||||
|
"forza": ForzaParser,
|
||||||
|
"ac": ACParser,
|
||||||
|
"acc": ACCParser,
|
||||||
|
"f1": F1Parser,
|
||||||
|
"iracing": IRacingParser,
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
|
||||||
|
|
||||||
|
class BaseParser(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def parse(self, data: bytes, addr: tuple[str, int]) -> TelemetryData:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def game_id(self) -> str:
|
||||||
|
...
|
||||||
|
|
||||||
|
def supports(self, raw_data: bytes) -> bool:
|
||||||
|
return True
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from server.telemetry.listener import telemetry_listener
|
||||||
|
from server.telemetry.data import TelemetryData
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self._connections: dict[str, WebSocket] = {}
|
||||||
|
self._counter = 0
|
||||||
|
self._broadcast_task: asyncio.Task | None = None
|
||||||
|
self._pending_data: TelemetryData | None = None
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket) -> str:
|
||||||
|
await ws.accept()
|
||||||
|
self._counter += 1
|
||||||
|
cid = f"client_{self._counter}"
|
||||||
|
self._connections[cid] = ws
|
||||||
|
logger.info("WS client connected: %s (total: %d)", cid, len(self._connections))
|
||||||
|
if not self._broadcast_task or self._broadcast_task.done():
|
||||||
|
self._broadcast_task = asyncio.create_task(self._broadcast_loop())
|
||||||
|
return cid
|
||||||
|
|
||||||
|
def disconnect(self, cid: str):
|
||||||
|
self._connections.pop(cid, None)
|
||||||
|
logger.info("WS client disconnected: %s (total: %d)", cid, len(self._connections))
|
||||||
|
if not self._connections and self._broadcast_task:
|
||||||
|
self._broadcast_task.cancel()
|
||||||
|
self._broadcast_task = None
|
||||||
|
|
||||||
|
def push_telemetry(self, data: TelemetryData):
|
||||||
|
self._pending_data = data
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict[str, Any]):
|
||||||
|
dead = []
|
||||||
|
for cid, ws in self._connections.items():
|
||||||
|
try:
|
||||||
|
await ws.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
dead.append(cid)
|
||||||
|
for cid in dead:
|
||||||
|
self.disconnect(cid)
|
||||||
|
|
||||||
|
async def _broadcast_loop(self):
|
||||||
|
last_sent = 0.0
|
||||||
|
throttle_interval = 1.0 / 30.0
|
||||||
|
try:
|
||||||
|
while self._connections:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_sent >= throttle_interval and self._pending_data:
|
||||||
|
data = self._pending_data
|
||||||
|
self._pending_data = None
|
||||||
|
last_sent = now
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "telemetry",
|
||||||
|
"data": data.to_dict(),
|
||||||
|
})
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_count(self) -> int:
|
||||||
|
return len(self._connections)
|
||||||
|
|
||||||
|
|
||||||
|
ws_manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
def on_telemetry(data: TelemetryData):
|
||||||
|
ws_manager.push_telemetry(data)
|
||||||
|
|
||||||
|
|
||||||
|
telemetry_listener.on_data(on_telemetry)
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
/* TurboSu Main Layout - HyperOS Design System
|
||||||
|
* Layout: TopAppBar + NavigationRail (sidebar) + Content
|
||||||
|
*/
|
||||||
|
|
||||||
|
.app-layout {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== NavigationRail: Sidebar (HyperOS NavigationRail style) ===== */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--divider);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: width var(--transition-slow) cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 100;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .nav-label,
|
||||||
|
.sidebar.collapsed .sidebar-title,
|
||||||
|
.sidebar.collapsed .collapse-arrow {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||||
|
}
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 12px;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.sidebar-toggle-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.sidebar-toggle-btn:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation items (HyperOS NavigationRail item style) */
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.nav-item:active {
|
||||||
|
background: rgba(128, 128, 128, 0.14);
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--accent-container);
|
||||||
|
color: var(--accent-on-container);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-toggle .collapse-arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform var(--transition-normal), opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.collapsible-toggle.collapsed .collapse-arrow {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 600px;
|
||||||
|
transition: max-height var(--transition-slow);
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
.nav-submenu.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
.nav-submenu .nav-item {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TopAppBar (HyperOS style) ===== */
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--topbar-height);
|
||||||
|
padding: 0 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.topbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.topbar-logo-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
.topbar-logo-title.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.topbar-title-text {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Connection status indicator */
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: var(--blur-small);
|
||||||
|
-webkit-backdrop-filter: var(--blur-small);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-indicator.connected .status-dot {
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 6px rgba(52, 199, 89, 0.4);
|
||||||
|
}
|
||||||
|
.status-indicator.disconnected .status-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 6px rgba(233, 70, 52, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .icon-sun { display: none; }
|
||||||
|
.theme-dark .icon-moon { display: none; }
|
||||||
|
|
||||||
|
/* ===== Main Content Area ===== */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
.page-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
.page.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Page Header ===== */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.page-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Home Page: Status Cards ===== */
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.status-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.status-card-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.status-card-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.status-card-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dashboard Page (Browser) ===== */
|
||||||
|
.dashboard-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar {
|
||||||
|
width: 170px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border-right: 1px solid var(--divider);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar .category-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar .category-item:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar .category-item.active {
|
||||||
|
background: var(--accent-container);
|
||||||
|
color: var(--accent-on-container);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.dashboard-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.theme-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.theme-card {
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
.theme-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.theme-card .theme-card-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 130px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 44px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.theme-card .theme-card-info {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.theme-card .theme-card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.theme-card .theme-card-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scene Page ===== */
|
||||||
|
.scene-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.scene-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.scene-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scene-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.scene-card {
|
||||||
|
padding: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-normal);
|
||||||
|
}
|
||||||
|
.scene-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.scene-card .scene-card-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.scene-card .scene-card-game {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-on-container);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.scene-card .scene-card-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.scene-canvas-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.scene-canvas-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Modal/Dialog (HyperOS OverlayDialog style) ===== */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--window-dimming);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease both;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 28px;
|
||||||
|
min-width: 620px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25);
|
||||||
|
animation: fadeInUp 0.25s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
.modal h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scene Editor Canvas ===== */
|
||||||
|
.scene-editor-canvas {
|
||||||
|
position: relative;
|
||||||
|
margin: 16px 0;
|
||||||
|
border: 2px dashed var(--outline);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
.scene-editor-placement {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: move;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--accent-container);
|
||||||
|
min-width: 80px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Debug Page ===== */
|
||||||
|
.debug-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.debug-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.debug-data {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.debug-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: var(--blur-small);
|
||||||
|
-webkit-backdrop-filter: var(--blur-small);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.debug-field .debug-field-name {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-on-container);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.debug-field .debug-field-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Settings Page ===== */
|
||||||
|
.settings-section {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.settings-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.settings-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.settings-control {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-control input[type="number"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.settings-control select {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Empty State ===== */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.empty-state svg {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.empty-state h4 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 13px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Toast / Snackbar (HyperOS Snackbar style) ===== */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-menu);
|
||||||
|
backdrop-filter: var(--blur-large);
|
||||||
|
-webkit-backdrop-filter: var(--blur-large);
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: fadeInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.toast.success { border-left: 3px solid var(--success); }
|
||||||
|
.toast.error { border-left: 3px solid var(--danger); }
|
||||||
|
.toast.info { border-left: 3px solid var(--accent); }
|
||||||
|
|
||||||
|
/* ===== Form ===== */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Code inline ===== */
|
||||||
|
code {
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
color: var(--accent-on-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mobile Bottom Navigation Bar (HyperOS NavigationBar) ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.topbar-logo-title.hidden {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.topbar-logo-title svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.topbar-title-text {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.status-card-value {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
padding: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.dashboard-sub-sidebar .category-item {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
min-width: auto;
|
||||||
|
max-width: 95vw;
|
||||||
|
margin: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile bottom nav */
|
||||||
|
.mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
z-index: 150;
|
||||||
|
backdrop-filter: var(--blur-large);
|
||||||
|
-webkit-backdrop-filter: var(--blur-large);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
.mobile-nav .nav-item {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.mobile-nav .nav-item svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.mobile-nav .nav-label {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-container > div:last-child {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.settings-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.settings-control input[type="number"],
|
||||||
|
.settings-control select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tablet Layout ===== */
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-collapsed-width);
|
||||||
|
}
|
||||||
|
.sidebar.expanded {
|
||||||
|
position: fixed;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 150;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
.theme-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Desktop ===== */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mobile-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
.theme-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== High-DPI / Retina ===== */
|
||||||
|
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
||||||
|
.glass, .glass-light, .glass-card {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Reduced Motion ===== */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/* Miuix CSS - HyperOS Design System for Web
|
||||||
|
* Color tokens match Xiaomi HyperOS design spec
|
||||||
|
* Reference: https://compose-miuix-ui.github.io/miuix/zh_CN/guide/colors
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent: #3482FF;
|
||||||
|
--accent-hover: #277AF7;
|
||||||
|
--danger: #E94634;
|
||||||
|
--success: #34C759;
|
||||||
|
--warning: #FF9F0A;
|
||||||
|
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 20px;
|
||||||
|
--radius-xxl: 24px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0,0,0,0.10);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
|
||||||
|
--transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--transition-slow: 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
--sidebar-width: 260px;
|
||||||
|
--sidebar-collapsed-width: 60px;
|
||||||
|
--topbar-height: 56px;
|
||||||
|
|
||||||
|
--blur-small: blur(6px) saturate(140%);
|
||||||
|
--blur-medium: blur(12px) saturate(160%);
|
||||||
|
--blur-large: blur(20px) saturate(180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Light Theme (HyperOS Light) ===== */
|
||||||
|
.theme-light {
|
||||||
|
--bg-primary: #FFFFFF;
|
||||||
|
--bg-secondary: #F7F7F7;
|
||||||
|
--bg-tertiary: #F0F0F0;
|
||||||
|
--bg-glass: rgba(255, 255, 255, 0.82);
|
||||||
|
--bg-glass-hover: rgba(255, 255, 255, 0.90);
|
||||||
|
--bg-glass-active: rgba(240, 240, 240, 0.88);
|
||||||
|
--bg-card: rgba(255, 255, 255, 0.78);
|
||||||
|
--bg-card-hover: rgba(255, 255, 255, 0.90);
|
||||||
|
--bg-input: rgba(248, 248, 248, 0.9);
|
||||||
|
--bg-input-focus: rgba(240, 245, 255, 0.95);
|
||||||
|
--bg-menu: rgba(255, 255, 255, 0.95);
|
||||||
|
--bg-tooltip: rgba(30, 30, 30, 0.92);
|
||||||
|
|
||||||
|
--text-primary: #000000;
|
||||||
|
--text-secondary: #8C93B0;
|
||||||
|
--text-tertiary: #959595;
|
||||||
|
--text-inverse: #FFFFFF;
|
||||||
|
--text-link: #3482FF;
|
||||||
|
--text-disabled: #B2B2B2;
|
||||||
|
|
||||||
|
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||||
|
--border-normal: rgba(0, 0, 0, 0.08);
|
||||||
|
--border-strong: rgba(0, 0, 0, 0.12);
|
||||||
|
--outline: #D9D9D9;
|
||||||
|
--divider: #E0E0E0;
|
||||||
|
|
||||||
|
--accent-container: #EAF2FF;
|
||||||
|
--accent-on-container: #3482FF;
|
||||||
|
--error-container: #FDF6F4;
|
||||||
|
--error-on-container: #410002;
|
||||||
|
|
||||||
|
--scrollbar-bg: rgba(0, 0, 0, 0.04);
|
||||||
|
--scrollbar-thumb: rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dark Theme (HyperOS Dark) ===== */
|
||||||
|
.theme-dark {
|
||||||
|
--bg-primary: #000000;
|
||||||
|
--bg-secondary: #242424;
|
||||||
|
--bg-tertiary: #2D2D2D;
|
||||||
|
--bg-glass: rgba(36, 36, 36, 0.72);
|
||||||
|
--bg-glass-hover: rgba(45, 45, 45, 0.78);
|
||||||
|
--bg-glass-active: rgba(50, 50, 50, 0.82);
|
||||||
|
--bg-card: rgba(36, 36, 36, 0.65);
|
||||||
|
--bg-card-hover: rgba(45, 45, 45, 0.75);
|
||||||
|
--bg-input: rgba(30, 30, 30, 0.8);
|
||||||
|
--bg-input-focus: rgba(40, 40, 55, 0.9);
|
||||||
|
--bg-menu: rgba(36, 36, 36, 0.95);
|
||||||
|
--bg-tooltip: rgba(20, 20, 20, 0.92);
|
||||||
|
|
||||||
|
--text-primary: rgba(255, 255, 255, 0.90);
|
||||||
|
--text-secondary: rgba(255, 255, 255, 0.62);
|
||||||
|
--text-tertiary: rgba(255, 255, 255, 0.42);
|
||||||
|
--text-inverse: #000000;
|
||||||
|
--text-link: #277AF7;
|
||||||
|
--text-disabled: #666666;
|
||||||
|
|
||||||
|
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||||
|
--border-normal: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--outline: #404040;
|
||||||
|
--divider: #393939;
|
||||||
|
|
||||||
|
--accent-container: rgba(39, 122, 247, 0.12);
|
||||||
|
--accent-on-container: #277AF7;
|
||||||
|
--error-container: #2E0603;
|
||||||
|
--error-on-container: #FFDAD6;
|
||||||
|
|
||||||
|
--scrollbar-bg: rgba(255, 255, 255, 0.03);
|
||||||
|
--scrollbar-thumb: rgba(255, 255, 255, 0.10);
|
||||||
|
|
||||||
|
--window-dimming: rgba(0, 0, 0, 0.60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Global Reset ===== */
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', 'MiSans', 'Microsoft YaHei', system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition: background var(--transition-normal), color var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--text-link); text-decoration: none; }
|
||||||
|
button { cursor: pointer; border: none; outline: none; font-family: inherit; }
|
||||||
|
|
||||||
|
/* ===== Inputs ===== */
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--outline);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color var(--transition-fast), background var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--bg-input-focus);
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 130, 255, 0.15);
|
||||||
|
}
|
||||||
|
input::placeholder, textarea::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Surface Components (HyperOS style) ===== */
|
||||||
|
.surface {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.surface-high {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.surface-highest {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Glass Surface (blur effect) ===== */
|
||||||
|
.glass {
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: var(--blur-large);
|
||||||
|
-webkit-backdrop-filter: var(--blur-large);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.glass-light {
|
||||||
|
background: var(--bg-glass);
|
||||||
|
backdrop-filter: var(--blur-medium);
|
||||||
|
-webkit-backdrop-filter: var(--blur-medium);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
.glass-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: var(--blur-medium);
|
||||||
|
-webkit-backdrop-filter: var(--blur-medium);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: background var(--transition-normal), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
.glass-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.glass-card:active {
|
||||||
|
transform: scale(0.985);
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Buttons (HyperOS style) ===== */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.btn::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.btn:hover::after {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 6px rgba(52, 130, 255, 0.25);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 4px 12px rgba(52, 130, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-glass-hover);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm { padding: 6px 14px; font-size: 12px; border-radius: var(--radius-sm); }
|
||||||
|
.btn-lg { padding: 14px 28px; font-size: 16px; border-radius: var(--radius-md); }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--bg-glass-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.icon-btn:active {
|
||||||
|
background: var(--bg-glass-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Badge ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-success { background: rgba(52, 199, 89, 0.15); color: var(--success); }
|
||||||
|
.badge-warning { background: rgba(255, 159, 10, 0.15); color: var(--warning); }
|
||||||
|
.badge-danger { background: rgba(233, 70, 52, 0.15); color: var(--danger); }
|
||||||
|
.badge-info { background: var(--accent-container); color: var(--accent-on-container); }
|
||||||
|
.badge-secondary { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ===== Divider ===== */
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--divider);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
.animated {
|
||||||
|
animation: fadeInUp 0.35s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% { transform: scale(0.8); opacity: 1; }
|
||||||
|
100% { transform: scale(1.4); opacity: 0; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const API = {
|
||||||
|
_base: '/api',
|
||||||
|
|
||||||
|
async get(path) {
|
||||||
|
const res = await fetch(`${this._base}${path}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async post(path, data) {
|
||||||
|
const res = await fetch(`${this._base}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async put(path, data) {
|
||||||
|
const res = await fetch(`${this._base}${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async del(path) {
|
||||||
|
const res = await fetch(`${this._base}${path}`, { method: 'DELETE' });
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStatus() { return this.get('/status'); },
|
||||||
|
async getConfig() { return this.get('/config'); },
|
||||||
|
async updateConfig(data) { return this.put('/config', data); },
|
||||||
|
|
||||||
|
async getGames() { return this.get('/games'); },
|
||||||
|
|
||||||
|
async getDashboards(category) { return this.get(`/dashboards?category=${category || 'all'}`); },
|
||||||
|
async getCategories() { return this.get('/dashboards/categories'); },
|
||||||
|
async getDashboard(id) { return this.get(`/dashboards/${id}`); },
|
||||||
|
async getDashboardTemplate(id) { return this.get(`/dashboards/${id}/template`); },
|
||||||
|
async createDashboard(data) { return this.post('/dashboards', data); },
|
||||||
|
async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); },
|
||||||
|
async deleteDashboard(id) { return this.del(`/dashboards/${id}`); },
|
||||||
|
async exportDashboard(id) { return this.get(`/dashboards/${id}/export`); },
|
||||||
|
async importDashboard(data) { return this.post('/dashboards/import', data); },
|
||||||
|
|
||||||
|
async getScenes(gameId) { return this.get(`/scenes?game_id=${gameId || ''}`); },
|
||||||
|
async getScene(id) { return this.get(`/scenes/${id}`); },
|
||||||
|
async createScene(data) { return this.post('/scenes', data); },
|
||||||
|
async updateScene(id, data) { return this.put(`/scenes/${id}`, data); },
|
||||||
|
async deleteScene(id) { return this.del(`/scenes/${id}`); },
|
||||||
|
async exportScene(id) { return this.get(`/scenes/${id}/export`); },
|
||||||
|
async importScene(data) { return this.post('/scenes/import', data); },
|
||||||
|
|
||||||
|
async startTelemetry() { return this.post('/telemetry/start'); },
|
||||||
|
async stopTelemetry() { return this.post('/telemetry/stop'); },
|
||||||
|
async getLatestTelemetry() { return this.get('/telemetry/latest'); },
|
||||||
|
|
||||||
|
async installGamePlugin(data) { return this.post('/games/install', data); },
|
||||||
|
async removeGamePlugin(id) { return this.del(`/games/${id}`); },
|
||||||
|
async exportGamePlugin(id) { return this.get(`/games/${id}/export`); },
|
||||||
|
async reloadGamePlugins() { return this.post('/games/reload'); },
|
||||||
|
};
|
||||||
|
|
||||||
|
window.API = API;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
(function () {
|
||||||
|
Theme.init();
|
||||||
|
WS.init();
|
||||||
|
Sidebar.init();
|
||||||
|
Topbar.init();
|
||||||
|
PageHome.init();
|
||||||
|
PageDashboard.init();
|
||||||
|
PageScene.init();
|
||||||
|
PageDebug.init();
|
||||||
|
PageSettings.init();
|
||||||
|
Router.init();
|
||||||
|
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||||||
|
Theme.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem('turbosu-theme');
|
||||||
|
if (savedTheme && savedTheme !== Theme.current) {
|
||||||
|
Theme._current = savedTheme;
|
||||||
|
Theme.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('%cTurboSu %cReady',
|
||||||
|
'color:#667eea;font-size:16px;font-weight:bold;',
|
||||||
|
'color:#a8a8c0;');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
const Sidebar = {
|
||||||
|
_el: null,
|
||||||
|
_gameListEl: null,
|
||||||
|
_gameToggleEl: null,
|
||||||
|
_gameLabelEl: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('sidebar');
|
||||||
|
this._gameListEl = document.getElementById('game-list');
|
||||||
|
this._gameToggleEl = document.getElementById('game-selector-toggle');
|
||||||
|
this._gameLabelEl = document.getElementById('game-selector-label');
|
||||||
|
this._topbarLogo = document.getElementById('topbar-logo-title');
|
||||||
|
|
||||||
|
document.getElementById('sidebar-toggle').addEventListener('click', () => this.toggle());
|
||||||
|
this._gameToggleEl.addEventListener('click', () => this._toggleGameList());
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('turbosu-sidebar-collapsed');
|
||||||
|
if (saved === 'true') this.collapse(true);
|
||||||
|
|
||||||
|
this._loadGames();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this._el.classList.toggle('collapsed');
|
||||||
|
this._el.classList.toggle('expanded');
|
||||||
|
const isCollapsed = this._el.classList.contains('collapsed');
|
||||||
|
this._topbarLogo.classList.toggle('hidden', !isCollapsed);
|
||||||
|
localStorage.setItem('turbosu-sidebar-collapsed', isCollapsed);
|
||||||
|
},
|
||||||
|
|
||||||
|
collapse(silent) {
|
||||||
|
this._el.classList.add('collapsed');
|
||||||
|
this._el.classList.remove('expanded');
|
||||||
|
this._topbarLogo.classList.remove('hidden');
|
||||||
|
if (!silent) localStorage.setItem('turbosu-sidebar-collapsed', 'true');
|
||||||
|
},
|
||||||
|
|
||||||
|
expand(silent) {
|
||||||
|
this._el.classList.remove('collapsed');
|
||||||
|
this._el.classList.add('expanded');
|
||||||
|
this._topbarLogo.classList.add('hidden');
|
||||||
|
if (!silent) localStorage.setItem('turbosu-sidebar-collapsed', 'false');
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadGames() {
|
||||||
|
const games = await API.getGames();
|
||||||
|
this._gameListEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!Array.isArray(games) || games.length === 0) {
|
||||||
|
this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">暂无可用的游戏插件</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedId = (await API.getConfig()).selected_game_id;
|
||||||
|
games.forEach(game => {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'nav-item';
|
||||||
|
if (selectedId === game.id) item.classList.add('active');
|
||||||
|
item.innerHTML = `
|
||||||
|
<span style="font-size:16px;flex-shrink:0;">◉</span>
|
||||||
|
<span class="nav-label">${game.name}</span>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => this._selectGame(game.id));
|
||||||
|
this._gameListEl.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updateGameLabel(selectedId, games);
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateGameLabel(selectedId, games) {
|
||||||
|
if (selectedId && games) {
|
||||||
|
const game = games.find(g => g.id === selectedId);
|
||||||
|
if (game) {
|
||||||
|
this._gameLabelEl.textContent = game.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._gameLabelEl.textContent = '选择游戏';
|
||||||
|
},
|
||||||
|
|
||||||
|
async _selectGame(gameId) {
|
||||||
|
await API.updateConfig({ selected_game_id: gameId });
|
||||||
|
await API.startTelemetry();
|
||||||
|
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||||
|
const items = this._gameListEl.querySelectorAll('.nav-item');
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.textContent.trim()) item.classList.add('active');
|
||||||
|
});
|
||||||
|
const games = await API.getGames();
|
||||||
|
this._updateGameLabel(gameId, games);
|
||||||
|
this._loadGames();
|
||||||
|
|
||||||
|
if (typeof Router !== 'undefined' && Router.currentPage === 'scene') {
|
||||||
|
Router.navigate('scene');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_toggleGameList() {
|
||||||
|
const toggle = this._gameToggleEl;
|
||||||
|
const list = this._gameListEl;
|
||||||
|
const isCollapsed = toggle.classList.contains('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
toggle.classList.remove('collapsed');
|
||||||
|
list.classList.remove('collapsed');
|
||||||
|
} else {
|
||||||
|
toggle.classList.add('collapsed');
|
||||||
|
list.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setActive(route) {
|
||||||
|
this._el.querySelectorAll('.nav-item[data-route]').forEach(el => {
|
||||||
|
el.classList.toggle('active', el.getAttribute('data-route') === route);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Sidebar = Sidebar;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const Topbar = {
|
||||||
|
_statusEl: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._statusEl = document.getElementById('connection-status');
|
||||||
|
|
||||||
|
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||||||
|
Theme.toggle();
|
||||||
|
});
|
||||||
|
|
||||||
|
WS.on('status', (status) => {
|
||||||
|
this._statusEl.className = `status-indicator ${status}`;
|
||||||
|
this._statusEl.querySelector('.status-text').textContent =
|
||||||
|
status === 'connected' ? '已连接' :
|
||||||
|
status === 'error' ? '连接错误' : '未连接';
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => this._refreshStatus(), 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _refreshStatus() {
|
||||||
|
try {
|
||||||
|
const status = await API.getStatus();
|
||||||
|
const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected';
|
||||||
|
this._statusEl.className = `status-indicator ${wsStatus}`;
|
||||||
|
this._statusEl.querySelector('.status-text').textContent =
|
||||||
|
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Topbar = Topbar;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
const PageDashboard = {
|
||||||
|
_el: null,
|
||||||
|
_currentCategory: 'all',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-dashboard');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
await this._loadCategories();
|
||||||
|
await this._loadThemes();
|
||||||
|
this._bindEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadCategories() {
|
||||||
|
try {
|
||||||
|
const cats = await API.getCategories();
|
||||||
|
const listEl = document.getElementById('dash-category-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const allItem = document.createElement('div');
|
||||||
|
allItem.className = 'category-item active';
|
||||||
|
allItem.textContent = '全部';
|
||||||
|
allItem.dataset.cat = 'all';
|
||||||
|
listEl.appendChild(allItem);
|
||||||
|
|
||||||
|
cats.forEach(cat => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'category-item';
|
||||||
|
item.textContent = cat;
|
||||||
|
item.dataset.cat = cat;
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load categories:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadThemes() {
|
||||||
|
const gridEl = document.getElementById('dash-theme-grid');
|
||||||
|
if (!gridEl) return;
|
||||||
|
|
||||||
|
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const themes = await API.getDashboards(this._currentCategory);
|
||||||
|
if (!themes || themes.length === 0) {
|
||||||
|
gridEl.innerHTML = `
|
||||||
|
<div class="empty-state" style="grid-column:1/-1;">
|
||||||
|
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
|
||||||
|
<h4>暂无仪表盘主题</h4>
|
||||||
|
<p>创建或导入你的第一个仪表盘主题</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load themes:', e);
|
||||||
|
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
const categoryList = document.getElementById('dash-category-list');
|
||||||
|
if (categoryList) {
|
||||||
|
categoryList.addEventListener('click', (e) => {
|
||||||
|
const item = e.target.closest('.category-item');
|
||||||
|
if (!item) return;
|
||||||
|
categoryList.querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
this._currentCategory = item.dataset.cat;
|
||||||
|
this._loadThemes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridEl = document.getElementById('dash-theme-grid');
|
||||||
|
if (gridEl) {
|
||||||
|
gridEl.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('.theme-card');
|
||||||
|
if (!card) return;
|
||||||
|
const themeId = card.dataset.themeId;
|
||||||
|
this._openDashboard(themeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_openDashboard(themeId) {
|
||||||
|
const url = `${location.origin}/dashboard/${themeId}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
Toast.show('链接已复制,可在局域网设备访问', 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
Toast.show(`打开地址: ${url}`, 'info');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_themeCardHtml(theme) {
|
||||||
|
const icon = theme.config?.icon || '📊';
|
||||||
|
const aspect = theme.config?.aspect_ratio || 'auto';
|
||||||
|
return `
|
||||||
|
<div class="glass-card theme-card" data-theme-id="${theme.id}">
|
||||||
|
<div class="theme-card-preview">${icon}</div>
|
||||||
|
<div class="theme-card-info">
|
||||||
|
<div class="theme-card-name">${theme.name || 'Unnamed'}</div>
|
||||||
|
<div class="theme-card-meta">
|
||||||
|
<span>${theme.category || 'basic'}</span>
|
||||||
|
<span>${aspect}</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-meta" style="margin-top:4px;">
|
||||||
|
<span>${theme.author || ''}</span>
|
||||||
|
<span class="badge ${theme.is_builtin ? 'badge-info' : 'badge-success'}">${theme.is_builtin ? '内置' : '自定义'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_template() {
|
||||||
|
return `
|
||||||
|
<div class="dashboard-layout">
|
||||||
|
<div class="dashboard-sub-sidebar">
|
||||||
|
<div id="dash-category-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-main">
|
||||||
|
<div id="dash-theme-grid" class="theme-grid"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageDashboard = PageDashboard;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
const PageDebug = {
|
||||||
|
_el: null,
|
||||||
|
_autoScroll: true,
|
||||||
|
_paused: false,
|
||||||
|
_rawData: [],
|
||||||
|
_fieldData: {},
|
||||||
|
_maxRawLines: 200,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-debug');
|
||||||
|
},
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
this._bindEvents();
|
||||||
|
WS.on('telemetry', (data) => this._onTelemetry(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('debug-clear-btn')?.addEventListener('click', () => {
|
||||||
|
this._rawData = [];
|
||||||
|
this._fieldData = {};
|
||||||
|
this._renderFields();
|
||||||
|
this._renderRaw();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('debug-pause-btn')?.addEventListener('click', () => {
|
||||||
|
this._paused = !this._paused;
|
||||||
|
const btn = document.getElementById('debug-pause-btn');
|
||||||
|
btn.textContent = this._paused ? '▶ 继续' : '⏸ 暂停';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
|
||||||
|
this._autoScroll = e.target.checked;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_onTelemetry(data) {
|
||||||
|
if (this._paused) return;
|
||||||
|
|
||||||
|
this._fieldData = data;
|
||||||
|
this._renderFields();
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
this._rawData.push({ timestamp, data: { ...data } });
|
||||||
|
if (this._rawData.length > this._maxRawLines) {
|
||||||
|
this._rawData.shift();
|
||||||
|
}
|
||||||
|
this._renderRaw();
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderFields() {
|
||||||
|
const container = document.getElementById('debug-fields');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const keys = Object.keys(this._fieldData).filter(k => k !== 'raw');
|
||||||
|
container.innerHTML = keys.map(k => {
|
||||||
|
const val = this._fieldData[k];
|
||||||
|
const displayVal = typeof val === 'number' ? val.toFixed(3) : val;
|
||||||
|
return `
|
||||||
|
<div class="debug-field">
|
||||||
|
<div class="debug-field-name">${k}</div>
|
||||||
|
<div class="debug-field-value">${displayVal}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderRaw() {
|
||||||
|
const el = document.getElementById('debug-raw');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const lines = this._rawData.map(entry => {
|
||||||
|
const ts = entry.timestamp.substring(11, 23);
|
||||||
|
const preview = JSON.stringify(entry.data).substring(0, 300);
|
||||||
|
return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
el.innerHTML = lines.join('\n') || '等待数据...';
|
||||||
|
|
||||||
|
if (this._autoScroll) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
WS.off('telemetry', this._onTelemetry);
|
||||||
|
},
|
||||||
|
|
||||||
|
_template() {
|
||||||
|
return `
|
||||||
|
<div class="debug-container">
|
||||||
|
<div class="debug-toolbar animated">
|
||||||
|
<h2 style="font-size:20px;font-weight:700;">数据测试</h2>
|
||||||
|
<span style="flex:1;"></span>
|
||||||
|
<button id="debug-pause-btn" class="btn btn-sm btn-secondary">⏸ 暂停</button>
|
||||||
|
<button id="debug-clear-btn" class="btn btn-sm btn-secondary">清空</button>
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
|
||||||
|
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
|
||||||
|
<div style="overflow-y:auto;">
|
||||||
|
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">解析后的数据字段</h4>
|
||||||
|
<div id="debug-fields">
|
||||||
|
<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-y:auto;">
|
||||||
|
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">原始 JSON 数据流</h4>
|
||||||
|
<div id="debug-raw" class="debug-data"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageDebug = PageDebug;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const PageHome = {
|
||||||
|
_el: null,
|
||||||
|
_refreshTimer: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-home');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
this._startRefresh();
|
||||||
|
await this._refreshCards();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _refreshCards() {
|
||||||
|
try {
|
||||||
|
const status = await API.getStatus();
|
||||||
|
this._updateCard('card-server', status.server_running ? '运行中' : '已停止', status.server_running ? 'success' : 'danger');
|
||||||
|
this._updateCard('card-telemetry', status.telemetry_running ? '监听中' : '未启动', status.telemetry_running ? 'success' : 'warning');
|
||||||
|
this._updateCard('card-game', status.selected_game_id || '未选择', 'info');
|
||||||
|
this._updateCard('card-connections', `${status.ws_clients} 个客户端`, status.ws_clients > 0 ? 'success' : 'secondary');
|
||||||
|
this._updateCard('card-packets', `${status.packet_count} 包`, status.packet_count > 0 ? 'success' : 'secondary');
|
||||||
|
|
||||||
|
const lastTime = status.last_packet_time;
|
||||||
|
if (lastTime > 0) {
|
||||||
|
const ago = Math.round((Date.now() / 1000) - lastTime);
|
||||||
|
this._updateCard('card-last-packet', `${ago}秒前`, ago < 5 ? 'success' : 'warning');
|
||||||
|
} else {
|
||||||
|
this._updateCard('card-last-packet', '暂无数据', 'secondary');
|
||||||
|
}
|
||||||
|
|
||||||
|
const td = status.latest_data;
|
||||||
|
if (td) {
|
||||||
|
this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary');
|
||||||
|
this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary');
|
||||||
|
this._updateCard('card-gear', `档位 ${td.gear || 'N'}`, 'primary');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh status:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateCard(id, value, type) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const valEl = el.querySelector('.status-card-value');
|
||||||
|
if (valEl) valEl.textContent = value;
|
||||||
|
const badgeEl = el.querySelector('.badge');
|
||||||
|
if (badgeEl) {
|
||||||
|
badgeEl.className = `badge badge-${type}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_startRefresh() {
|
||||||
|
if (this._refreshTimer) clearInterval(this._refreshTimer);
|
||||||
|
this._refreshTimer = setInterval(() => this._refreshCards(), 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this._refreshTimer) {
|
||||||
|
clearInterval(this._refreshTimer);
|
||||||
|
this._refreshTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_template() {
|
||||||
|
return `
|
||||||
|
<div class="animated">
|
||||||
|
<h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">系统概览</h2>
|
||||||
|
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p>
|
||||||
|
</div>
|
||||||
|
<div class="status-grid animated" style="animation-delay:0.1s">
|
||||||
|
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')}
|
||||||
|
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')}
|
||||||
|
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
|
||||||
|
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
|
||||||
|
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
|
||||||
|
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
|
||||||
|
</div>
|
||||||
|
<div class="status-grid animated" style="animation-delay:0.2s">
|
||||||
|
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')}
|
||||||
|
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')}
|
||||||
|
${this._cardHtml('card-gear', '当前档位', 'N', 'primary')}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_cardHtml(id, title, value, type) {
|
||||||
|
return `
|
||||||
|
<div class="glass-card status-card" id="${id}">
|
||||||
|
<div class="status-card-header">
|
||||||
|
<span class="status-card-title">${title}</span>
|
||||||
|
<span class="badge badge-${type}">●</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-card-value">${value}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageHome = PageHome;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
const PageScene = {
|
||||||
|
_el: null,
|
||||||
|
_currentGameId: null,
|
||||||
|
_currentGameName: '',
|
||||||
|
_editorSceneId: null,
|
||||||
|
_editorData: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-scene');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const cfg = await API.getConfig();
|
||||||
|
this._currentGameId = cfg.selected_game_id;
|
||||||
|
|
||||||
|
if (this._currentGameId) {
|
||||||
|
const games = await API.getGames();
|
||||||
|
const game = games.find(g => g.id === this._currentGameId);
|
||||||
|
this._currentGameName = game ? game.name : this._currentGameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
await this._loadScenes();
|
||||||
|
this._bindEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadScenes() {
|
||||||
|
const grid = document.getElementById('scene-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const scenes = await API.getScenes(this._currentGameId);
|
||||||
|
if (!scenes || scenes.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="empty-state" style="grid-column:1/-1;">
|
||||||
|
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
|
||||||
|
<h4>暂无场景</h4>
|
||||||
|
<p>点击"新建场景"创建你的第一个场景布局</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load scenes:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor());
|
||||||
|
|
||||||
|
const grid = document.getElementById('scene-grid');
|
||||||
|
if (grid) {
|
||||||
|
grid.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('[data-scene-id]');
|
||||||
|
if (!card) return;
|
||||||
|
const action = card.dataset.action;
|
||||||
|
const sceneId = card.dataset.sceneId;
|
||||||
|
|
||||||
|
if (action === 'render') {
|
||||||
|
this._renderScene(sceneId);
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._showEditor(sceneId);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._deleteScene(sceneId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _showEditor(sceneId) {
|
||||||
|
let scene = null;
|
||||||
|
if (sceneId) {
|
||||||
|
scene = await API.getScene(sceneId);
|
||||||
|
if (!scene) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._editorSceneId = sceneId;
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.innerHTML = this._editorTemplate(scene);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.querySelector('.modal-overlay, .modal-actions .btn-secondary')
|
||||||
|
?.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal || e.target.matches('.btn-secondary')) {
|
||||||
|
modal.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelector('.btn-primary')?.addEventListener('click', async () => {
|
||||||
|
const name = modal.querySelector('#edit-scene-name').value || 'New Scene';
|
||||||
|
const desc = modal.querySelector('#edit-scene-desc').value || '';
|
||||||
|
const gameId = this._currentGameId || '';
|
||||||
|
|
||||||
|
const data = { name, description: desc, game_id: gameId };
|
||||||
|
if (this._editorSceneId) {
|
||||||
|
await API.updateScene(this._editorSceneId, data);
|
||||||
|
} else {
|
||||||
|
await API.createScene(data);
|
||||||
|
}
|
||||||
|
modal.remove();
|
||||||
|
Toast.show('场景保存成功', 'success');
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _renderScene(sceneId) {
|
||||||
|
const url = `${location.origin}/scene/${sceneId}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
Toast.show('场景链接已复制,可在局域网设备访问', 'success');
|
||||||
|
}).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _deleteScene(sceneId) {
|
||||||
|
if (!confirm('确定删除此场景?')) return;
|
||||||
|
await API.deleteScene(sceneId);
|
||||||
|
Toast.show('场景已删除', 'info');
|
||||||
|
this._loadScenes();
|
||||||
|
},
|
||||||
|
|
||||||
|
_sceneCardHtml(scene) {
|
||||||
|
const canvasCount = (scene.canvases || []).length;
|
||||||
|
return `
|
||||||
|
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
|
||||||
|
<div class="scene-card-name">${scene.name}</div>
|
||||||
|
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div>
|
||||||
|
<div class="scene-card-meta">
|
||||||
|
<span>${canvasCount} 个画布</span>
|
||||||
|
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="scene-canvas-list">
|
||||||
|
${(scene.canvases || []).map(c => `<span class="scene-canvas-badge">${c.label || c.width + 'x' + c.height}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px;display:flex;gap:6px;">
|
||||||
|
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button>
|
||||||
|
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_editorTemplate(scene) {
|
||||||
|
const name = scene ? scene.name : '';
|
||||||
|
const desc = scene ? scene.description : '';
|
||||||
|
return `
|
||||||
|
<div class="modal">
|
||||||
|
<h3>${scene ? '编辑场景' : '新建场景'}</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>场景名称</label>
|
||||||
|
<input id="edit-scene-name" type="text" value="${name}" placeholder="输入场景名称">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>描述</label>
|
||||||
|
<textarea id="edit-scene-desc" placeholder="场景描述(可选)">${desc}</textarea>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--text-tertiary);margin-top:8px;">
|
||||||
|
提示:保存后可在场景编辑器中添加仪表盘、调整布局和画布比例。
|
||||||
|
</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary">取消</button>
|
||||||
|
<button class="btn btn-primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_template() {
|
||||||
|
return `
|
||||||
|
<div class="scene-header animated">
|
||||||
|
<div>
|
||||||
|
<h2>场景管理</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;">
|
||||||
|
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-new-scene" class="btn btn-primary" ${!this._currentGameId ? 'disabled' : ''}>
|
||||||
|
+ 新建场景
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="scene-grid" class="scene-grid animated"></div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageScene = PageScene;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const PageSettings = {
|
||||||
|
_el: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-settings');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const cfg = await API.getConfig();
|
||||||
|
const games = await API.getGames();
|
||||||
|
this._el.innerHTML = this._template(cfg, games);
|
||||||
|
this._bindEvents();
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
|
||||||
|
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
|
||||||
|
Toast.show('遥测端口已更新,重启监听后生效', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-server-port')?.addEventListener('change', async (e) => {
|
||||||
|
Toast.show('服务器端口修改后需要重启程序', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
|
||||||
|
await API.stopTelemetry();
|
||||||
|
await API.startTelemetry();
|
||||||
|
Toast.show('遥测监听已重启', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-import-plugin')?.addEventListener('click', () => this._importPlugin());
|
||||||
|
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
|
||||||
|
await API.reloadGamePlugins();
|
||||||
|
Toast.show('插件已重新加载', 'success');
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-game-list')?.addEventListener('click', async (e) => {
|
||||||
|
const exportBtn = e.target.closest('.btn-export-plugin');
|
||||||
|
const removeBtn = e.target.closest('.btn-remove-plugin');
|
||||||
|
if (exportBtn) {
|
||||||
|
const pluginId = exportBtn.dataset.pluginId;
|
||||||
|
const data = await API.exportGamePlugin(pluginId);
|
||||||
|
if (data) {
|
||||||
|
this._downloadJson(`plugin_${pluginId}.json`, data);
|
||||||
|
Toast.show('插件已导出', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removeBtn) {
|
||||||
|
const pluginId = removeBtn.dataset.pluginId;
|
||||||
|
if (confirm('确定移除这个游戏插件?')) {
|
||||||
|
await API.removeGamePlugin(pluginId);
|
||||||
|
Toast.show('插件已移除', 'info');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _importPlugin() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (data.type === 'game_plugin') {
|
||||||
|
await API.installGamePlugin(data);
|
||||||
|
Toast.show('插件安装成功', 'success');
|
||||||
|
this.render();
|
||||||
|
} else {
|
||||||
|
Toast.show('无效的插件文件格式', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show('文件解析失败: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
_downloadJson(filename, data) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
_gamePluginListHtml(games) {
|
||||||
|
if (!games || games.length === 0) {
|
||||||
|
return '<p style="color:var(--text-tertiary);font-size:13px;">暂无游戏插件</p>';
|
||||||
|
}
|
||||||
|
return games.map(g => `
|
||||||
|
<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;">${g.name}</div>
|
||||||
|
<div style="font-size:12px;color:var(--text-tertiary);">
|
||||||
|
${g.description || ''} | v${g.version} | ${g.author || ''}
|
||||||
|
<span class="badge ${g.is_builtin ? 'badge-info' : 'badge-success'}" style="margin-left:6px;">
|
||||||
|
${g.is_builtin ? '内置' : '社区'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="${g.id}">导出</button>
|
||||||
|
${!g.is_builtin ? `<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="${g.id}">移除</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
_template(cfg, games) {
|
||||||
|
return `
|
||||||
|
<div class="animated">
|
||||||
|
<h2 style="font-size:24px;font-weight:700;margin-bottom:24px;">设置</h2>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>连接设置</h3>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-label">遥测监听端口</div>
|
||||||
|
<div class="settings-desc">游戏内设置的数据输出端口</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-control">
|
||||||
|
<input type="number" id="settings-telemetry-port" value="${cfg.telemetry_port || 20777}" min="1024" max="65535">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-label">Web 服务器端口</div>
|
||||||
|
<div class="settings-desc">Web UI 服务的端口号</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-control">
|
||||||
|
<input type="number" id="settings-server-port" value="${cfg.server_port || 9527}" min="80" max="65535" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-label">重启遥测监听</div>
|
||||||
|
<div class="settings-desc">修改端口或切换游戏后需要重启监听</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-control">
|
||||||
|
<button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>游戏插件管理</h3>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:16px;">
|
||||||
|
<button id="btn-import-plugin" class="btn btn-secondary btn-sm">📥 导入插件</button>
|
||||||
|
<button id="btn-reload-plugins" class="btn btn-secondary btn-sm">🔄 重新加载</button>
|
||||||
|
</div>
|
||||||
|
<div id="settings-game-list">
|
||||||
|
${this._gamePluginListHtml(games)}
|
||||||
|
</div>
|
||||||
|
<div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6;">
|
||||||
|
<strong style="color:var(--text-secondary);">社区开发指南:</strong><br>
|
||||||
|
1. 创建一个包含 <code>manifest.json</code> 和 <code>parser.py</code> 的文件夹<br>
|
||||||
|
2. <code>manifest.json</code> 定义游戏元信息,<code>parser.py</code> 实现 <code>get_parser()</code> 函数<br>
|
||||||
|
3. <code>get_parser()</code> 返回对象需实现 <code>game_id()</code> 和 <code>parse(data, addr)</code> 方法<br>
|
||||||
|
4. 通过"导入插件"或放入 <code>games/user/</code> 目录安装<br>
|
||||||
|
5. 导出你的插件分享给社区!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>关于</h3>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-label">TurboSu</div>
|
||||||
|
<div class="settings-desc">赛车遥测仪表盘 v1.0.0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageSettings = PageSettings;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const Router = {
|
||||||
|
_routes: {
|
||||||
|
home: PageHome,
|
||||||
|
dashboard: PageDashboard,
|
||||||
|
scene: PageScene,
|
||||||
|
debug: PageDebug,
|
||||||
|
settings: PageSettings,
|
||||||
|
},
|
||||||
|
_currentPage: 'home',
|
||||||
|
_currentPageInstance: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
window.addEventListener('hashchange', () => this._handleRoute());
|
||||||
|
this._handleRoute();
|
||||||
|
|
||||||
|
document.querySelectorAll('.nav-item[data-route]').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const route = el.getAttribute('data-route');
|
||||||
|
if (route) this.navigate(route);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
navigate(page) {
|
||||||
|
window.location.hash = `#/${page}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_handleRoute() {
|
||||||
|
const hash = window.location.hash || '#/home';
|
||||||
|
const page = hash.replace('#/', '') || 'home';
|
||||||
|
|
||||||
|
if (this._currentPageInstance && this._currentPageInstance.cleanup) {
|
||||||
|
this._currentPageInstance.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||||||
|
const pageEl = document.getElementById(`page-${page}`);
|
||||||
|
if (pageEl) pageEl.classList.add('active');
|
||||||
|
|
||||||
|
Sidebar.setActive(page);
|
||||||
|
|
||||||
|
const route = this._routes[page];
|
||||||
|
if (route) {
|
||||||
|
this._currentPage = page;
|
||||||
|
this._currentPageInstance = route;
|
||||||
|
route.render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get currentPage() { return this._currentPage; }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Router = Router;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const Theme = {
|
||||||
|
_current: 'dark',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._current = localStorage.getItem('turbosu-theme') || 'dark';
|
||||||
|
this.apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this._current = this._current === 'dark' ? 'light' : 'dark';
|
||||||
|
this.apply();
|
||||||
|
},
|
||||||
|
|
||||||
|
apply() {
|
||||||
|
document.body.classList.remove('theme-dark', 'theme-light');
|
||||||
|
document.body.classList.add(`theme-${this._current}`);
|
||||||
|
localStorage.setItem('turbosu-theme', this._current);
|
||||||
|
},
|
||||||
|
|
||||||
|
get current() { return this._current; }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Theme = Theme;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const Toast = {
|
||||||
|
_container: null,
|
||||||
|
|
||||||
|
_init() {
|
||||||
|
if (this._container) return;
|
||||||
|
this._container = document.createElement('div');
|
||||||
|
this._container.className = 'toast-container';
|
||||||
|
document.body.appendChild(this._container);
|
||||||
|
},
|
||||||
|
|
||||||
|
show(message, type = 'info', duration = 3000) {
|
||||||
|
this._init();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
this._container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transform = 'translateX(100%)';
|
||||||
|
toast.style.transition = 'all 0.3s ease';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Toast = Toast;
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const WS = {
|
||||||
|
_ws: null,
|
||||||
|
_url: '',
|
||||||
|
_reconnectTimer: null,
|
||||||
|
_reconnectDelay: 2000,
|
||||||
|
_handlers: {},
|
||||||
|
_connected: false,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
this._url = `${protocol}//${location.host}/ws`;
|
||||||
|
this._connect();
|
||||||
|
},
|
||||||
|
|
||||||
|
_connect() {
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._ws = new WebSocket(this._url);
|
||||||
|
} catch (e) {
|
||||||
|
this._scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ws.onopen = () => {
|
||||||
|
this._connected = true;
|
||||||
|
this._reconnectDelay = 2000;
|
||||||
|
this._emit('connection', true);
|
||||||
|
this._emit('status', 'connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
this._emit(msg.type || 'message', msg.data || msg);
|
||||||
|
if (msg.type === 'telemetry') {
|
||||||
|
this._emit('telemetry', msg.data);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onclose = () => {
|
||||||
|
this._connected = false;
|
||||||
|
this._emit('connection', false);
|
||||||
|
this._emit('status', 'disconnected');
|
||||||
|
this._scheduleReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
this._ws.onerror = () => {
|
||||||
|
this._emit('status', 'error');
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
_scheduleReconnect() {
|
||||||
|
if (this._reconnectTimer) return;
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 10000);
|
||||||
|
this._connect();
|
||||||
|
}, this._reconnectDelay);
|
||||||
|
},
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
if (!this._handlers[event]) this._handlers[event] = [];
|
||||||
|
this._handlers[event].push(handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
off(event, handler) {
|
||||||
|
if (!this._handlers[event]) return;
|
||||||
|
this._handlers[event] = this._handlers[event].filter(h => h !== handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
_emit(event, data) {
|
||||||
|
if (!this._handlers[event]) return;
|
||||||
|
this._handlers[event].forEach(h => h(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
send(data) {
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
|
||||||
|
this._ws.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get connected() { return this._connected; }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.WS = WS;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>TurboSu - {{ theme.name if theme else 'Dashboard' }}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--aspect-ratio: {{ theme.config.aspect_ratio | default('auto') if theme else 'auto' }};
|
||||||
|
--render-mode: {{ theme.config.render_mode | default('contain') if theme else 'contain' }};
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'SF Pro Text', -apple-system, 'PingFang SC', 'MiSans', system-ui, sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
#dashboard-root {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#dashboard-letterbox {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#dashboard-content {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100%; font-size: 24px; color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aspect ratio constraint: "contain" mode */
|
||||||
|
#dashboard-letterbox.contain {
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
/* Aspect ratio constraint: "cover" mode */
|
||||||
|
#dashboard-letterbox.cover {
|
||||||
|
min-width: 100vw;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
/* Aspect ratio constraint: "fill" mode */
|
||||||
|
#dashboard-letterbox.fill {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
/* Aspect ratio constraint: "center" mode */
|
||||||
|
#dashboard-letterbox.center {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="dashboard-root">
|
||||||
|
<div id="dashboard-letterbox" class="{{ theme.config.render_mode | default('contain') if theme else 'contain' }}">
|
||||||
|
<div id="dashboard-content">
|
||||||
|
{{ template_html | safe if template_html else '<div class="loading">加载仪表盘中...</div>' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const THEME_ID = "{{ theme.id if theme else '' }}";
|
||||||
|
const THEME_CONFIG = {{ theme.config | tojson if theme and theme.config else '{}' }};
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${location.host}/ws`;
|
||||||
|
const letterbox = document.getElementById('dashboard-letterbox');
|
||||||
|
const root = document.getElementById('dashboard-root');
|
||||||
|
let ws;
|
||||||
|
let reconnectTimer;
|
||||||
|
|
||||||
|
function parseAspectRatio(ratio) {
|
||||||
|
if (!ratio || ratio === 'auto') return null;
|
||||||
|
const parts = ratio.split(':');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const w = parseFloat(parts[0]);
|
||||||
|
const h = parseFloat(parts[1]);
|
||||||
|
if (w > 0 && h > 0) return w / h;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAspectRatio() {
|
||||||
|
const ratio = parseAspectRatio(THEME_CONFIG.aspect_ratio || 'auto');
|
||||||
|
const mode = THEME_CONFIG.render_mode || 'contain';
|
||||||
|
|
||||||
|
if (!ratio) {
|
||||||
|
letterbox.style.width = '100vw';
|
||||||
|
letterbox.style.height = '100vh';
|
||||||
|
letterbox.classList.add('fill');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const vr = vw / vh;
|
||||||
|
|
||||||
|
let cw, ch;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'contain':
|
||||||
|
if (vr > ratio) {
|
||||||
|
ch = vh;
|
||||||
|
cw = vh * ratio;
|
||||||
|
} else {
|
||||||
|
cw = vw;
|
||||||
|
ch = vw / ratio;
|
||||||
|
}
|
||||||
|
letterbox.classList.add('contain');
|
||||||
|
break;
|
||||||
|
case 'cover':
|
||||||
|
if (vr > ratio) {
|
||||||
|
cw = vw;
|
||||||
|
ch = vw / ratio;
|
||||||
|
} else {
|
||||||
|
ch = vh;
|
||||||
|
cw = vh * ratio;
|
||||||
|
}
|
||||||
|
letterbox.classList.add('cover');
|
||||||
|
break;
|
||||||
|
case 'fill':
|
||||||
|
letterbox.style.width = '100vw';
|
||||||
|
letterbox.style.height = '100vh';
|
||||||
|
letterbox.classList.add('fill');
|
||||||
|
return;
|
||||||
|
case 'center':
|
||||||
|
const maxW = THEME_CONFIG.max_width || 1920;
|
||||||
|
const maxH = THEME_CONFIG.max_height || 1080;
|
||||||
|
cw = Math.min(vw, maxW);
|
||||||
|
ch = cw / ratio;
|
||||||
|
if (ch > vh) {
|
||||||
|
ch = Math.min(vh, maxH);
|
||||||
|
cw = ch * ratio;
|
||||||
|
}
|
||||||
|
letterbox.classList.add('center');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (vr > ratio) {
|
||||||
|
ch = vh;
|
||||||
|
cw = vh * ratio;
|
||||||
|
} else {
|
||||||
|
cw = vw;
|
||||||
|
ch = vw / ratio;
|
||||||
|
}
|
||||||
|
letterbox.classList.add('contain');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== 'fill') {
|
||||||
|
letterbox.style.width = cw + 'px';
|
||||||
|
letterbox.style.height = ch + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('[Dashboard] WS connected');
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'telemetry') updateDashboard(msg.data);
|
||||||
|
} catch(e) {}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('[Dashboard] WS closed, reconnecting...');
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = (e) => console.error('[Dashboard] WS error', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard(data) {
|
||||||
|
document.querySelectorAll('[data-bind]').forEach(el => {
|
||||||
|
const bind = el.getAttribute('data-bind');
|
||||||
|
const value = bind.split('.').reduce((o, k) => o?.[k], data);
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||||
|
el.value = typeof value === 'number' ? value.toFixed(1) : value;
|
||||||
|
} else {
|
||||||
|
el.textContent = typeof value === 'number' ? value.toFixed(1) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bind-rpm]').forEach(el => {
|
||||||
|
const value = data.rpm || 0;
|
||||||
|
const max = data.max_rpm || 8000;
|
||||||
|
const pct = Math.min(value / max, 1);
|
||||||
|
el.style.setProperty('--rpm-pct', pct);
|
||||||
|
el.style.setProperty('--rpm', value);
|
||||||
|
el.style.setProperty('--rpm-max', max);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bind-speed]').forEach(el => {
|
||||||
|
el.style.setProperty('--speed', data.speed_kmh || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bind-gear]').forEach(el => {
|
||||||
|
const g = data.gear || 0;
|
||||||
|
el.style.setProperty('--gear', g);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAspectRatio();
|
||||||
|
window.addEventListener('resize', applyAspectRatio);
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TurboSu - 赛车遥测仪表盘</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/miuix.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="theme-dark">
|
||||||
|
<div id="app" class="app-layout">
|
||||||
|
<aside id="sidebar" class="sidebar expanded">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<svg viewBox="0 0 48 48" width="40" height="40">
|
||||||
|
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
|
||||||
|
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
|
||||||
|
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">S</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-title">TurboSu</div>
|
||||||
|
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<a href="#/home" class="nav-item" data-route="home">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">首页</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/dashboard" class="nav-item" data-route="dashboard">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">仪表盘</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div id="game-selector-container" class="nav-section">
|
||||||
|
<button id="game-selector-toggle" class="nav-item collapsible-toggle collapsed">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H3V8h18v8zM6 15h2v-2h2v-2H8V9H6v2H4v2h2z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label" id="game-selector-label">选择游戏</span>
|
||||||
|
<svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="game-list" class="nav-submenu collapsed">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="scene-nav-container" class="nav-section">
|
||||||
|
<a href="#/scene" class="nav-item" data-route="scene">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">场景</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<a href="#/debug" class="nav-item" data-route="debug">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">数据测试</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/settings" class="nav-item" data-route="settings">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">设置</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<header id="topbar" class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<div id="topbar-logo-title" class="topbar-logo-title hidden">
|
||||||
|
<svg viewBox="0 0 48 48" width="28" height="28">
|
||||||
|
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
|
||||||
|
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
|
||||||
|
</svg>
|
||||||
|
<span class="topbar-title-text">TurboSu</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div id="connection-status" class="status-indicator disconnected">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="status-text">未连接</span>
|
||||||
|
</div>
|
||||||
|
<button id="theme-toggle" class="icon-btn" title="切换日夜间模式">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM4 10.5H1v2h3zm9-9.95h-2V3.5h2zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5v2h3v-2zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41z" fill="currentColor"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20"><path d="M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="page-container" class="page-container">
|
||||||
|
<div id="page-home" class="page active">
|
||||||
|
</div>
|
||||||
|
<div id="page-dashboard" class="page">
|
||||||
|
</div>
|
||||||
|
<div id="page-scene" class="page">
|
||||||
|
</div>
|
||||||
|
<div id="page-debug" class="page">
|
||||||
|
</div>
|
||||||
|
<div id="page-settings" class="page">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/utils/theme.js"></script>
|
||||||
|
<script src="/static/js/utils/toast.js"></script>
|
||||||
|
<script src="/static/js/api.js"></script>
|
||||||
|
<script src="/static/js/ws.js"></script>
|
||||||
|
<script src="/static/js/components/sidebar.js"></script>
|
||||||
|
<script src="/static/js/components/topbar.js"></script>
|
||||||
|
<script src="/static/js/pages/home.js"></script>
|
||||||
|
<script src="/static/js/pages/dashboard.js"></script>
|
||||||
|
<script src="/static/js/pages/scene.js"></script>
|
||||||
|
<script src="/static/js/pages/debug.js"></script>
|
||||||
|
<script src="/static/js/pages/settings.js"></script>
|
||||||
|
<script src="/static/js/router.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TurboSu - {{ scene.name if scene else 'Scene' }}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||||
|
#scene-root {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#scene-canvas {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.scene-dashboard {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.scene-dashboard iframe {
|
||||||
|
width: 100%; height: 100%; border: none;
|
||||||
|
}
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,0.9); z-index: 999;
|
||||||
|
flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.loading-overlay select {
|
||||||
|
padding: 10px 20px; font-size: 16px; border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.1); color: #fff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.3); cursor: pointer;
|
||||||
|
}
|
||||||
|
.loading-overlay button {
|
||||||
|
padding: 12px 40px; font-size: 18px; border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff; border: none; cursor: pointer; font-weight: bold;
|
||||||
|
}
|
||||||
|
.loading-overlay button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="scene-root">
|
||||||
|
<div id="loading-overlay" class="loading-overlay">
|
||||||
|
<h2 style="color:#fff;font-size:24px;margin-bottom:8px;">选择画布比例</h2>
|
||||||
|
<select id="canvas-select">
|
||||||
|
</select>
|
||||||
|
<button id="start-render-btn">开始渲染</button>
|
||||||
|
</div>
|
||||||
|
<div id="scene-canvas"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const SCENE_DATA = {{ scene | tojson if scene else '{}' }};
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${location.host}/ws`;
|
||||||
|
const sceneRoot = document.getElementById('scene-root');
|
||||||
|
const sceneCanvas = document.getElementById('scene-canvas');
|
||||||
|
const loadingOverlay = document.getElementById('loading-overlay');
|
||||||
|
const canvasSelect = document.getElementById('canvas-select');
|
||||||
|
const startBtn = document.getElementById('start-render-btn');
|
||||||
|
|
||||||
|
const canvases = SCENE_DATA.canvases || [];
|
||||||
|
if (canvases.length === 0) {
|
||||||
|
loadingOverlay.innerHTML = '<p style="color:#f44;">该场景没有配置画布</p>';
|
||||||
|
} else if (canvases.length === 1) {
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
setupCanvas(canvases[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvases.forEach((c, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = `${c.label || 'Custom'} (${c.width}x${c.height})`;
|
||||||
|
canvasSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
startBtn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(canvasSelect.value);
|
||||||
|
if (idx >= 0 && idx < canvases.length) {
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
setupCanvas(canvases[idx]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupCanvas(canvas) {
|
||||||
|
const cw = canvas.width;
|
||||||
|
const ch = canvas.height;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
const scaleX = vw / cw;
|
||||||
|
const scaleY = vh / ch;
|
||||||
|
const scale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
|
sceneCanvas.style.width = (cw * scale) + 'px';
|
||||||
|
sceneCanvas.style.height = (ch * scale) + 'px';
|
||||||
|
|
||||||
|
const placements = canvas.placements || [];
|
||||||
|
placements.forEach(p => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'scene-dashboard';
|
||||||
|
el.style.left = (p.x * scale) + 'px';
|
||||||
|
el.style.top = (p.y * scale) + 'px';
|
||||||
|
el.style.width = (p.width * scale) + 'px';
|
||||||
|
el.style.height = (p.height * scale) + 'px';
|
||||||
|
el.style.zIndex = p.z_index || 0;
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = `/dashboard/${p.dashboard_id}`;
|
||||||
|
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
|
||||||
|
el.appendChild(iframe);
|
||||||
|
sceneCanvas.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const cw = parseInt(sceneCanvas.style.width) / (parseInt(sceneCanvas.style.width) / 1920) || 1920;
|
||||||
|
const ch = parseInt(sceneCanvas.style.height) / (parseInt(sceneCanvas.style.height) / 1080) || 1080;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const scale = Math.min(vw / cw, vh / ch);
|
||||||
|
sceneCanvas.style.width = (cw * scale) + 'px';
|
||||||
|
sceneCanvas.style.height = (ch * scale) + 'px';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||||
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
LOG_FILE = LOG_DIR / "turbosu.log"
|
||||||
|
LOG_FORMAT = logging.Formatter(
|
||||||
|
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(level)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
fh = RotatingFileHandler(
|
||||||
|
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||||
|
)
|
||||||
|
fh.setLevel(logging.DEBUG)
|
||||||
|
fh.setFormatter(LOG_FORMAT)
|
||||||
|
logger.addHandler(fh)
|
||||||
|
|
||||||
|
ch = logging.StreamHandler(sys.stdout)
|
||||||
|
ch.setLevel(logging.INFO)
|
||||||
|
ch.setFormatter(LOG_FORMAT)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return setup_logger(name)
|
||||||
Reference in New Issue
Block a user