Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 860bc015e8 | |||
| 5cea0f9851 | |||
| 5bc2c18f06 | |||
| 4becc26e45 | |||
| e4fe499bef | |||
| 759d35bc93 | |||
| f715cb85e6 | |||
| 1ebea5eb1a | |||
| 36755cc78a | |||
| 5630d0e332 | |||
| 29d51ad59e | |||
| 5bb4a82ae0 | |||
| 2ca8dc53f4 | |||
| 9cb8c12c89 | |||
| fb36eba07d | |||
| 6c4e61f0c0 | |||
| e8c3c85193 | |||
| 91c4ed7dab | |||
| 877e6c1a8c | |||
| b60b35cbea | |||
| 22bfd6fc19 | |||
| 1260566e75 | |||
| 16537a6f49 | |||
| 7a03fd3ebd | |||
| 30697e4a64 | |||
| 958482e5e6 | |||
| 64bf6d9603 | |||
| c4035acf0c | |||
| d931e4b2b0 | |||
| 6dd91739f0 | |||
| 978ccacd17 | |||
| 32a98d0e84 | |||
| 97de708469 | |||
| 2d5cdc0d3b | |||
| 44c0383dc1 | |||
| 495cc4143f | |||
| 5665c86eb3 | |||
| 977ca89965 | |||
| 003c303231 | |||
| cc04897d17 | |||
| 3ea3f10100 | |||
| 2960d132ef | |||
| e94d862560 | |||
| c59c7a172c | |||
| 08e5729de3 | |||
| 6a58ec9d20 | |||
| 49db12910d | |||
| 7e5e5f4afa | |||
| a6adedaadb | |||
| b8f07c0cba | |||
| e3afb2ac9b | |||
| 1c73ec9db6 | |||
| 10aa14b6d5 | |||
| ba2481139b | |||
| fb5ebfe162 | |||
| 87608868f5 | |||
| 0fd96b7794 | |||
| 76d9cc3975 | |||
| ae4bffd79f | |||
| 2a51026587 | |||
| d04ab1138c | |||
| 3ffee330a5 | |||
| 7e3456d208 | |||
| 55b1539487 | |||
| b3ec4a37e2 | |||
| 5064db0032 | |||
| c65ae4035b | |||
| 40087e1c16 | |||
| c051fdbfaf | |||
| 8c78393fb5 | |||
| d3a269b5cd | |||
| 55a1fb00df | |||
| 865a239a7e | |||
| 93d5f38431 | |||
| 89b44f1288 | |||
| 93d4ec308a | |||
| f9256a564b | |||
| df8f33dcca | |||
| 5e3d565b38 | |||
| b434be2fd1 | |||
| b9359b3b47 | |||
| 59887d7d46 | |||
| 98440099a8 | |||
| 47341f1f76 | |||
| e6344b1916 | |||
| c4675f7a15 | |||
| 605830ac34 | |||
| 6cb0759630 | |||
| 93776c7d98 | |||
| f4a0869af5 | |||
| 5735602f7d | |||
| 242e451ca7 | |||
| 273bb8f7fb | |||
| 91ee54c2c5 | |||
| 70df32bb07 | |||
| 3bb4de6de6 | |||
| 1303529d31 | |||
| 8da69b5ef8 | |||
| e7e93d9664 | |||
| 3db849b681 | |||
| 683e573e9a | |||
| 06042a93a2 | |||
| 7e9211507b | |||
| 3514612f7a | |||
| 71d039ba82 | |||
| ea32fa841e | |||
| a2e7141338 | |||
| a9bd35baa8 | |||
| 4bba4b850d | |||
| e97d6ee36f | |||
| 222b89e717 | |||
| b44241c666 | |||
| 2daa4d48f1 | |||
| 02ffa33298 | |||
| 1d702103f1 | |||
| 9f009a02f5 | |||
| cd2106dbf8 | |||
| ff0abce5c0 | |||
| 5d5af6b6a7 | |||
| 16ada23d61 | |||
| 3b7edc4f14 | |||
| 90ab5f9c0b | |||
| 69b73e8ce0 | |||
| 5690d792a5 | |||
| 37d9f94505 | |||
| 58ebb42f8c | |||
| b00e59d5bf | |||
| bbaef43cfd | |||
| a08120c568 | |||
| 9091f2133d | |||
| e28be26ab3 | |||
| 41da7c03d5 | |||
| a51cec79c1 | |||
| 7bafe73d6d | |||
| 5854b4cc57 | |||
| 7ca1053175 | |||
| 4843c61841 |
+2
-2
@@ -34,8 +34,7 @@ Desktop.ini
|
|||||||
.directory
|
.directory
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/*.log
|
logs/turbosu_*.log*
|
||||||
logs/*.log.*
|
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
data/config.json
|
data/config.json
|
||||||
@@ -67,3 +66,4 @@ htmlcov/
|
|||||||
# Node (if any)
|
# Node (if any)
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
logs/turbosu.log
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "Alpha v0.1.0",
|
||||||
|
"date": "2026-07-26",
|
||||||
|
"changes": [
|
||||||
|
"8款内置仪表盘:速度表、转速表、档位指示器、圈速计时器、Forza复合仪表、F1复合仪表、G力圆图、轮胎温度矩阵",
|
||||||
|
"换挡指示灯 + 手机竖屏仪表盘",
|
||||||
|
"场景画布编辑器:拖放式布局",
|
||||||
|
"遥测录制与回放功能",
|
||||||
|
"仪表盘市场集成(PocketBase 后端)",
|
||||||
|
"数据转发到下游物理外设",
|
||||||
|
"中英文切换 + 日夜间模式",
|
||||||
|
"Miuix 设计语言 WebUI",
|
||||||
|
"局域网设备全屏显示",
|
||||||
|
"PWA 支持",
|
||||||
|
"7款游戏插件:Forza Horizon/Motorsport, AC/ACC, F1, iRacing"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 9527 20777/udp
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
# TurboSu
|
# TurboSu · 极速中枢
|
||||||
|
|
||||||
赛车遥测仪表盘 — 基于 Web 的 B/S 架构外置仪表盘,支持多种赛车游戏遥测数据实时显示。
|
基于 Web 的 B/S 架构赛车遥测仪表盘,支持多种赛车游戏遥测数据实时显示。
|
||||||
|
社区市场在线分享仪表盘和场景。
|
||||||
|
|
||||||
|
🌐 [turbosu.yeij.top](https://turbosu.yeij.top)
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **多游戏支持** — 插件化架构,内置 Forza Horizon 4/5、Forza Motorsport、Assetto Corsa/ACC、F1、iRacing
|
- **多游戏支持** — 插件化架构,内置 Forza Horizon、Forza Motorsport、AC/ACC、F1、iRacing
|
||||||
- **社区扩展** — 游戏插件可独立打包导入导出 (`.tsp`),方便社区贡献小众游戏支持
|
- **8 款内置仪表盘** — 速度表、转速弧、档位指示器、圈速计时器、Forza 复合仪表、F1 复合仪表、G 力圆图、轮胎温度矩阵、换挡指示灯、手机竖屏仪表
|
||||||
- **实时仪表盘** — 速度表、转速表、档位指示器、圈速计时器
|
- **场景编辑器** — 拖放式画布,自由组合多个仪表盘到同一画面
|
||||||
- **独立仪表盘** — 每个仪表盘是独立文件夹 (`manifest.json` + `index.html`),启动自动扫描
|
- **局域网共享** — 仪表盘/场景独立链接,手机/平板/笔记本均可全屏访问
|
||||||
- **场景模式** — 多仪表盘布局组合,支持多比例画布切换
|
- **比例约束渲染** — 仪表盘可指定比例和渲染模式
|
||||||
- **局域网共享** — 仪表盘/场景生成独立链接,手机/平板/笔记本均可全屏访问
|
- **社区市场** — 在线分享仪表盘和场景,登录即上传,管理员审核发布
|
||||||
- **跨端自适应** — 响应式布局:桌面侧边栏 / 平板折叠 / 手机底部导航
|
- **录制回放** — 录制原始 UDP 流,离线回放调试仪表盘
|
||||||
- **比例约束渲染** — 仪表盘可指定比例 (`auto`/`16:9`/`4:3`/`1:1`) 和渲染模式 (`contain`/`cover`/`fill`/`center`)
|
- **数据转发** — 原始 UDP 包转发到下游物理外设
|
||||||
- **HyperOS 风格** — Miuix 设计语言,毛玻璃模糊效果,日间/夜间模式
|
- **HyperOS 风格** — Miuix 设计语言,日间/夜间模式,中英文切换
|
||||||
- **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式,便于分享
|
- **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -29,10 +33,10 @@
|
|||||||
### 运行
|
### 运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone ssh://git@git.yeij.top:2222/AskaEth/TurboSu.git
|
git clone https://git.yeij.top/AskaEth/TurboSu.git
|
||||||
cd TurboSu
|
cd TurboSu
|
||||||
|
|
||||||
# 一键启动 (自动创建 venv 并安装依赖)
|
# 一键启动
|
||||||
./run.sh
|
./run.sh
|
||||||
|
|
||||||
# 或手动启动
|
# 或手动启动
|
||||||
@@ -82,7 +86,10 @@ python app.py
|
|||||||
|
|
||||||
- [仪表盘开发指南](docs/DASHBOARD_DEV.md) — 创建自定义仪表盘主题 (.tsd)
|
- [仪表盘开发指南](docs/DASHBOARD_DEV.md) — 创建自定义仪表盘主题 (.tsd)
|
||||||
- [游戏插件兼容开发指南](docs/PLUGIN_DEV.md) — 为任意赛车游戏编写遥测解析器 (.tsp)
|
- [游戏插件兼容开发指南](docs/PLUGIN_DEV.md) — 为任意赛车游戏编写遥测解析器 (.tsp)
|
||||||
- [Forza 数据包格式参考](docs/FH_DATA_FORMAT.md) — 官方 UDP 包 324 字节完整字段定义
|
- [Forza 数据包格式参考](docs/FH_DATA_FORMAT.md) — Forza Horizon UDP 324 字节完整定义
|
||||||
|
- [F1 24 数据包格式参考](docs/F1_DATA_FORMAT.md) — F1 24 UDP 遥测规范
|
||||||
|
- [ACC 数据包格式参考](docs/ACC_DATA_FORMAT.md) — Assetto Corsa Competizione 广播 API
|
||||||
|
- [iRacing 遥测格式参考](docs/IRACING_DATA_FORMAT.md) — iRacing IRSDK 遥测变量
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, FileResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
@@ -27,13 +27,29 @@ os.makedirs(BASE_DIR / "logs", exist_ok=True)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
import asyncio
|
||||||
|
import warnings
|
||||||
|
from websockets.exceptions import ConnectionClosedError
|
||||||
|
|
||||||
|
def _handle_async_exception(loop, ctx):
|
||||||
|
exc = ctx.get("exception")
|
||||||
|
if isinstance(exc, ConnectionClosedError):
|
||||||
|
return
|
||||||
|
loop.default_exception_handler(ctx)
|
||||||
|
|
||||||
|
asyncio.get_event_loop().set_exception_handler(_handle_async_exception)
|
||||||
|
warnings.filterwarnings("ignore", message=".*ping timeout.*")
|
||||||
|
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
logger.info(" TurboSu - Racing Telemetry Dashboard")
|
logger.info(" TurboSu - 极速中枢")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
|
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
|
||||||
await telemetry_listener.start()
|
await telemetry_listener.start()
|
||||||
logger.info("Telemetry listener auto-started")
|
logger.info("Telemetry listener auto-started")
|
||||||
|
if cfg.get("ui_test_mode"):
|
||||||
|
telemetry_listener.start_test_mode()
|
||||||
|
logger.info("Test mode auto-started (config)")
|
||||||
yield
|
yield
|
||||||
telemetry_listener.stop()
|
telemetry_listener.stop()
|
||||||
logger.info("TurboSu shutdown complete")
|
logger.info("TurboSu shutdown complete")
|
||||||
@@ -70,6 +86,35 @@ async def dashboard_render(request: Request, theme_id: str):
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard/{theme_id}/preview.{ext}")
|
||||||
|
async def dashboard_preview(theme_id: str, ext: str):
|
||||||
|
from models.dashboard import BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR
|
||||||
|
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
|
||||||
|
path = base / theme_id / f"preview.{ext}"
|
||||||
|
if path.exists():
|
||||||
|
return FileResponse(path)
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/dashboard/{theme_id}/{path:path}")
|
||||||
|
async def dashboard_static(theme_id: str, path: str):
|
||||||
|
from models.dashboard import BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR
|
||||||
|
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
|
||||||
|
filepath = base / theme_id / path
|
||||||
|
if filepath.exists() and filepath.is_file():
|
||||||
|
return FileResponse(filepath)
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/scene/{scene_id}/preview.{ext}")
|
||||||
|
async def scene_preview(scene_id: str, ext: str):
|
||||||
|
from models.scene import SCENES_DIR
|
||||||
|
path = SCENES_DIR / scene_id / f"preview.{ext}"
|
||||||
|
if path.exists():
|
||||||
|
return FileResponse(path)
|
||||||
|
raise HTTPException(404)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/scene/{scene_id}", response_class=HTMLResponse)
|
@app.get("/scene/{scene_id}", response_class=HTMLResponse)
|
||||||
async def scene_render(request: Request, scene_id: str):
|
async def scene_render(request: Request, scene_id: str):
|
||||||
from models.scene import scene_manager
|
from models.scene import scene_manager
|
||||||
@@ -96,15 +141,15 @@ async def websocket_endpoint(ws: WebSocket):
|
|||||||
await ws.send_json({"type": "pong"})
|
await ws.send_json({"type": "pong"})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error("WS error: %s", e)
|
pass
|
||||||
finally:
|
finally:
|
||||||
ws_manager.disconnect(cid)
|
ws_manager.disconnect(cid)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/favicon.ico")
|
||||||
async def health():
|
async def favicon():
|
||||||
return {"status": "ok"}
|
return FileResponse(STATIC_DIR / "img" / "logo.svg")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"sidebar_collapsed": False,
|
"sidebar_collapsed": False,
|
||||||
"telemetry_port": 20777,
|
"telemetry_port": 20777,
|
||||||
|
"tcp_port": 20778,
|
||||||
|
"use_unified_port": True,
|
||||||
|
"game_ports": {},
|
||||||
|
"ui_test_mode": False,
|
||||||
|
"market_url": "https://turbosu.yeij.top",
|
||||||
"telemetry_host": "0.0.0.0",
|
"telemetry_host": "0.0.0.0",
|
||||||
"server_host": "0.0.0.0",
|
"server_host": "0.0.0.0",
|
||||||
"server_port": 9527,
|
"server_port": 9527,
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||||
|
<title>TurboSu - F1 Composite</title>
|
||||||
|
<style>
|
||||||
|
@font-face{font-family:'NEXT ART';src:url('/static/fonts/NEXT_ART_Heavy.otf') format('opentype')}
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
|
||||||
|
font-family:'NEXT ART',monospace}
|
||||||
|
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||||
|
#box{position:relative;overflow:hidden}
|
||||||
|
#box.contain{max-width:100vw;max-height:100vh}
|
||||||
|
#box.cover{min-width:100vw;min-height:100vh}
|
||||||
|
#box.fill{width:100vw;height:100vh}
|
||||||
|
#content{position:relative;width:100%;height:100%;background:#0a0a0f}
|
||||||
|
|
||||||
|
#drs{position:absolute;right:4%;top:4%;padding:6px 18px;
|
||||||
|
border:1px solid rgba(255,255,255,.08);border-radius:4px;
|
||||||
|
font-size:min(1.6vw,24px);color:rgba(255,255,255,.15);letter-spacing:.3em;opacity:.5}
|
||||||
|
|
||||||
|
#left-col{position:absolute;left:4%;top:10%;width:58%}
|
||||||
|
#speed-val{font-size:min(16vw,300px);font-weight:200;line-height:.85;letter-spacing:-.02em}
|
||||||
|
#speed-unit{font-size:min(2.5vw,36px);color:rgba(255,255,255,.15);letter-spacing:.35em;margin-top:4px}
|
||||||
|
|
||||||
|
#rpm-row{display:flex;align-items:baseline;gap:12px;margin-top:1.5vh}
|
||||||
|
#rpm-val{font-size:min(8vw,140px);font-weight:200;line-height:1;letter-spacing:-.02em}
|
||||||
|
#rpm-unit{font-size:min(1.8vw,26px);color:rgba(255,255,255,.15);letter-spacing:.2em}
|
||||||
|
|
||||||
|
#lap-area{position:absolute;left:4%;top:58%;display:flex;align-items:flex-end;gap:min(4vw,60px)}
|
||||||
|
#lap-box{text-align:center}
|
||||||
|
#lap-label{font-size:min(1.4vw,20px);color:rgba(255,255,255,.15);letter-spacing:.25em;margin-bottom:2px}
|
||||||
|
#lap-num{font-size:min(5vw,72px);font-weight:200;line-height:1;letter-spacing:-.02em}
|
||||||
|
#lap-time{font-size:min(4vw,56px);font-weight:200;font-variant-numeric:tabular-nums;letter-spacing:-.02em;color:rgba(255,255,255,.6)}
|
||||||
|
#best-box{text-align:center}
|
||||||
|
#best-label{font-size:min(1.3vw,18px);color:rgba(255,255,255,.1);letter-spacing:.25em;margin-bottom:2px}
|
||||||
|
#best-time{font-size:min(2.8vw,40px);font-weight:200;font-variant-numeric:tabular-nums;color:rgba(255,255,255,.25)}
|
||||||
|
|
||||||
|
#gear-area{position:absolute;left:50%;transform:translateX(-50%);bottom:8%}
|
||||||
|
#gear-box{width:min(20vw,260px);height:min(20vw,260px);border-radius:50%;
|
||||||
|
border:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;
|
||||||
|
background:rgba(255,255,255,.02)}
|
||||||
|
#gear-val{font-size:min(14vw,220px);font-weight:200;line-height:1;transition:color .15s}
|
||||||
|
|
||||||
|
#right-col{position:absolute;right:4%;top:14%;width:20%}
|
||||||
|
.pedal{margin-bottom:3vh}
|
||||||
|
.pedal .pl{font-size:min(1.4vw,22px);color:rgba(255,255,255,.2);margin-bottom:6px;letter-spacing:.25em}
|
||||||
|
.pedal .pb{height:min(1.4vw,22px);background:rgba(255,255,255,.04);overflow:hidden}
|
||||||
|
.pedal .pf{height:100%;transition:width .08s linear}
|
||||||
|
#tf{background:rgba(255,255,255,.75)}#bf{background:rgba(255,255,255,.3)}
|
||||||
|
|
||||||
|
#fuel-box{margin-top:6vh}
|
||||||
|
#fuel-label{font-size:min(1.4vw,22px);color:rgba(255,255,255,.2);margin-bottom:6px;display:flex;justify-content:space-between;letter-spacing:.25em}
|
||||||
|
#fuel-label span:last-child{color:rgba(255,255,255,.35)}
|
||||||
|
#fuel-bar{height:min(1.4vw,22px);background:rgba(255,255,255,.04);overflow:hidden}
|
||||||
|
#fuel-fill{height:100%;background:rgba(255,255,255,.15);transition:width .5s}
|
||||||
|
|
||||||
|
#accel{position:absolute;right:28%;top:4%;text-align:right}
|
||||||
|
#accel div{font-size:min(1vw,15px);color:rgba(255,255,255,.08);letter-spacing:.15em;font-variant-numeric:tabular-nums}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"><div id="box" class="contain"><div id="content">
|
||||||
|
<div id="drs">DRS</div>
|
||||||
|
|
||||||
|
<div id="left-col">
|
||||||
|
<div id="speed-val">0</div>
|
||||||
|
<div id="speed-unit">KM/H</div>
|
||||||
|
<div id="rpm-row">
|
||||||
|
<span id="rpm-val">0</span>
|
||||||
|
<span id="rpm-unit">RPM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lap-area">
|
||||||
|
<div id="lap-box">
|
||||||
|
<div id="lap-label">LAP</div>
|
||||||
|
<div id="lap-num">0</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div id="lap-time">0:00.0</div>
|
||||||
|
</div>
|
||||||
|
<div id="best-box">
|
||||||
|
<div id="best-label">BEST</div>
|
||||||
|
<div id="best-time">--:--.-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gear-area"><div id="gear-box"><span id="gear-val">N</span></div></div>
|
||||||
|
|
||||||
|
<div id="right-col">
|
||||||
|
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
|
||||||
|
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
|
||||||
|
<div id="fuel-box">
|
||||||
|
<div id="fuel-label"><span>FUEL</span><span id="fuel-pct">100%</span></div>
|
||||||
|
<div id="fuel-bar"><div id="fuel-fill" style="width:100%"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="accel">
|
||||||
|
<div>G-LAT <span id="ax">0.00</span></div>
|
||||||
|
<div>G-LON <span id="az">0.00</span></div>
|
||||||
|
</div>
|
||||||
|
</div></div></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const M={aspect_ratio:"16:9",render_mode:"contain"};
|
||||||
|
(function(){
|
||||||
|
const box=document.getElementById('box'),sV=document.getElementById('speed-val'),
|
||||||
|
rV=document.getElementById('rpm-val'),gV=document.getElementById('gear-val'),
|
||||||
|
tF=document.getElementById('tf'),bF=document.getElementById('bf'),
|
||||||
|
fF=document.getElementById('fuel-fill'),fP=document.getElementById('fuel-pct'),
|
||||||
|
lN=document.getElementById('lap-num'),lT=document.getElementById('lap-time'),
|
||||||
|
bT=document.getElementById('best-time'),gBox=document.getElementById('gear-box'),
|
||||||
|
aX=document.getElementById('ax'),aZ=document.getElementById('az');
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||||
|
let ws,rt,lR=-1,lG=-999;
|
||||||
|
|
||||||
|
function fmt(t){if(!t||t<=0)return'0:00.0';const m=Math.floor(t/60),s=(t%60).toFixed(1);return m+':'+s.padStart(4,'0')}
|
||||||
|
function fmtB(t){if(!t||t<=0)return'--:--.-';return fmt(t)}
|
||||||
|
|
||||||
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||||
|
|
||||||
|
function apply(){
|
||||||
|
const rt=pr(M.aspect_ratio||'16:9'),md=M.render_mode||'contain';
|
||||||
|
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||||
|
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||||
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||||
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||||
|
box.style.width=cw+'px';box.style.height=ch+'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||||
|
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry')up(m.data)}catch(e){}}
|
||||||
|
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||||
|
|
||||||
|
function gearStr(g){
|
||||||
|
if(g===0||!g)return'N';
|
||||||
|
if(g>=1&&g<=8)return String(g);
|
||||||
|
return String(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
function up(d){
|
||||||
|
const rpm=d.rpm||0,mr=d.max_rpm||15000,g=d.gear||0;
|
||||||
|
|
||||||
|
sV.textContent=Math.round(d.speed_kmh||0);
|
||||||
|
rV.textContent=Math.round(rpm);
|
||||||
|
|
||||||
|
if(Math.abs(rpm-lR)>50||g!==lG){
|
||||||
|
lR=rpm;lG=g;
|
||||||
|
const pct=mr>0?Math.min(rpm/mr,1):0;
|
||||||
|
gBox.style.borderColor=pct>.9?'rgba(255,50,35,.5)':'rgba(255,255,255,.08)';
|
||||||
|
gV.style.color=pct>.9?'rgba(255,50,35,.95)':'#fff';
|
||||||
|
}
|
||||||
|
|
||||||
|
gV.textContent=gearStr(g);
|
||||||
|
tF.style.width=((d.throttle||0)*100).toFixed(0)+'%';
|
||||||
|
bF.style.width=((d.brake||0)*100).toFixed(0)+'%';
|
||||||
|
|
||||||
|
const fp=Math.round((d.fuel||1)*100);
|
||||||
|
fF.style.width=fp+'%';fP.textContent=fp+'%';
|
||||||
|
|
||||||
|
lN.textContent=d.lap_number||0;
|
||||||
|
lT.textContent=fmt(d.lap_time);
|
||||||
|
bT.textContent=fmtB(d.best_lap);
|
||||||
|
|
||||||
|
aX.textContent=(d.acceleration_x||0).toFixed(2);
|
||||||
|
aZ.textContent=(d.acceleration_z||0).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply();window.addEventListener('resize',()=>{apply()});cn();
|
||||||
|
setTimeout(()=>{apply()},100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "f1_composite",
|
||||||
|
"name": "F1 复合仪表盘",
|
||||||
|
"category": "f1",
|
||||||
|
"description": "F1 专属复合仪表:速度+RPM大字+档位圆环+油门刹车行程+油量+圈速",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"supported_games": "f1_24",
|
||||||
|
"config": {
|
||||||
|
"icon": "🏎️",
|
||||||
|
"aspect_ratio": "16:9",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,14 +18,14 @@ html,body{width:100%;height:100%;overflow:hidden;background:#060a16;color:#fff;
|
|||||||
#rpm-area{position:absolute;left:2%;top:4%;width:62%;height:82%}
|
#rpm-area{position:absolute;left:2%;top:4%;width:62%;height:82%}
|
||||||
#rpm-canvas{width:100%;height:100%}
|
#rpm-canvas{width:100%;height:100%}
|
||||||
#speed-area{position:absolute;left:4%;bottom:10%;width:60%;text-align:center}
|
#speed-area{position:absolute;left:4%;bottom:10%;width:60%;text-align:center}
|
||||||
#speed-val{font-size:min(24vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em}
|
#speed-val{font-size:min(12vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em}
|
||||||
#speed-unit{font-size:min(6vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px}
|
#speed-unit{font-size:min(3vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px}
|
||||||
#gear-area{position:absolute;right:12%;top:50%;transform:translateY(-55%)}
|
#gear-area{position:absolute;right:12%;top:50%;transform:translateY(-55%)}
|
||||||
#gear-box{width:min(24vw,200px);height:min(24vw,200px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center}
|
#gear-box{width:min(24vw,200px);height:min(24vw,200px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center}
|
||||||
#gear-box span{font-size:min(96vw,800px);font-weight:200;transition:.1s}
|
#gear-box span{font-size:min(48vw,800px);font-weight:200;transition:.1s}
|
||||||
#pedals{position:absolute;right:4%;top:6%;width:22%}
|
#pedals{position:absolute;right:4%;top:6%;width:22%}
|
||||||
.pedal{margin-bottom:22px}
|
.pedal{margin-bottom:22px}
|
||||||
.pedal .pl{font-size:min(9.6vw,68px);color:rgba(255,255,255,.25);margin-bottom:8px;letter-spacing:.15em}
|
.pedal .pl{font-size:min(4.8vw,68px);color:rgba(255,255,255,.25);margin-bottom:8px;letter-spacing:.15em}
|
||||||
.pedal .pb{height:24px;background:rgba(255,255,255,.06);overflow:hidden}
|
.pedal .pb{height:24px;background:rgba(255,255,255,.06);overflow:hidden}
|
||||||
.pedal .pf{height:100%;transition:width .06s linear}
|
.pedal .pf{height:100%;transition:width .06s linear}
|
||||||
#tf{background:#fff}#bf{background:rgba(255,255,255,.4)}
|
#tf{background:#fff}#bf{background:rgba(255,255,255,.4)}
|
||||||
@@ -41,7 +41,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:#060a16;color:#fff;
|
|||||||
<div id="root"><div id="box" class="contain"><div id="content">
|
<div id="root"><div id="box" class="contain"><div id="content">
|
||||||
<div id="rpm-area"><canvas id="rpm-canvas"></canvas></div>
|
<div id="rpm-area"><canvas id="rpm-canvas"></canvas></div>
|
||||||
<div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div>
|
<div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div>
|
||||||
<div id="gear-area"><div id="gear-box"><span id="gear-val">1</span></div></div>
|
<div id="gear-area"><div id="gear-box"><span id="gear-val">N</span></div></div>
|
||||||
<div id="pedals">
|
<div id="pedals">
|
||||||
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
|
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
|
||||||
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
|
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
|
||||||
@@ -145,7 +145,7 @@ function draw(cur,max){
|
|||||||
ctx.beginPath();ctx.moveTo(p.x,ey-gw/2-4);ctx.lineTo(p.x,ey+gw/2+4);
|
ctx.beginPath();ctx.moveTo(p.x,ey-gw/2-4);ctx.lineTo(p.x,ey+gw/2+4);
|
||||||
ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
|
ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
|
||||||
ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke();
|
ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke();
|
||||||
if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),p.x,ey+gw/2+22)}
|
if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),p.x,ey-gw/2-10)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ function draw(cur,max){
|
|||||||
ctx.fillStyle='#fff';ctx.font=`200 ${Math.max(128,W/4.5)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';
|
ctx.fillStyle='#fff';ctx.font=`200 ${Math.max(128,W/4.5)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';
|
||||||
ctx.fillText(Math.round(cur).toLocaleString(),sx+160,sy-260);
|
ctx.fillText(Math.round(cur).toLocaleString(),sx+160,sy-260);
|
||||||
ctx.fillStyle='rgba(255,255,255,.12)';ctx.font=`${Math.max(32,W/17)|0}px 'NEXT ART',sans-serif`;
|
ctx.fillStyle='rgba(255,255,255,.12)';ctx.font=`${Math.max(32,W/17)|0}px 'NEXT ART',sans-serif`;
|
||||||
ctx.fillText('\u00d71000 rpm',sx+160,sy-180);
|
ctx.fillText('rpm',sx+160,sy-180);
|
||||||
|
|
||||||
// redline
|
// redline
|
||||||
ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]);
|
ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]);
|
||||||
@@ -171,7 +171,8 @@ function up(d){const rpm=d.rpm||0,mr=d.max_rpm||8000,g=d.gear||0,sp=d.speed_kmh|
|
|||||||
const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%';
|
const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%';
|
||||||
const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}}
|
const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}}
|
||||||
|
|
||||||
draw(0,8000);apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn()
|
apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn();
|
||||||
|
setTimeout(()=>{apply();draw(0,8000)},100);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,90 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||||
|
<title>TurboSu - G力</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
|
||||||
|
font-family:'SF Pro Display',-apple-system,system-ui,sans-serif}
|
||||||
|
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||||
|
#box{position:relative;overflow:hidden}
|
||||||
|
#box.contain{max-width:100vw;max-height:100vh}
|
||||||
|
#box.cover{min-width:100vw;min-height:100vh}
|
||||||
|
#box.fill{width:100vw;height:100vh}
|
||||||
|
#content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#0a0a0f}
|
||||||
|
canvas{width:100%;height:100%}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"><div id="box" class="contain"><div id="content"><canvas id="g-canvas"></canvas></div></div></div>
|
||||||
|
<script>
|
||||||
|
const M={aspect_ratio:"1:1",render_mode:"contain"};
|
||||||
|
(function(){
|
||||||
|
const box=document.getElementById('box'),canvas=document.getElementById('g-canvas');
|
||||||
|
const ctx=canvas.getContext('2d');
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||||
|
let ws,rt,ax=0,ay=0,az=0;
|
||||||
|
|
||||||
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||||
|
|
||||||
|
function apply(){
|
||||||
|
const rt=pr(M.aspect_ratio||'1:1'),md=M.render_mode||'contain';
|
||||||
|
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||||
|
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||||
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||||
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||||
|
box.style.width=cw+'px';box.style.height=ch+'px';draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sz(){const r=canvas.parentElement.getBoundingClientRect(),d=window.devicePixelRatio||1;canvas.width=r.width*d;canvas.height=r.height*d;canvas.style.width=r.width+'px';canvas.style.height=r.height+'px';ctx.setTransform(d,0,0,d,0,0)}
|
||||||
|
|
||||||
|
function draw(){
|
||||||
|
sz();const W=canvas.width/devicePixelRatio,H=canvas.height/devicePixelRatio,dpr=window.devicePixelRatio||1;
|
||||||
|
ctx.clearRect(0,0,W,H);
|
||||||
|
const cx=W/2,cy=H/2,r=Math.min(W,H)*.38;
|
||||||
|
const G=9.81,limit=2.0;
|
||||||
|
|
||||||
|
// outer ring
|
||||||
|
ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.strokeStyle='rgba(255,255,255,.1)';ctx.lineWidth=2;ctx.stroke();
|
||||||
|
// crosshairs
|
||||||
|
ctx.beginPath();ctx.moveTo(cx-r,cy);ctx.lineTo(cx+r,cy);ctx.moveTo(cx,cy-r);ctx.lineTo(cx,cy+r);ctx.strokeStyle='rgba(255,255,255,.06)';ctx.lineWidth=1;ctx.stroke();
|
||||||
|
// 1G ring
|
||||||
|
ctx.beginPath();ctx.arc(cx,cy,r/limit,0,Math.PI*2);ctx.setLineDash([4,8]);ctx.strokeStyle='rgba(255,255,255,.08)';ctx.lineWidth=1;ctx.stroke();ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// G dot
|
||||||
|
const x=Math.max(-limit,Math.min(limit,ax/G)),y=Math.max(-limit,Math.min(limit,az/G));
|
||||||
|
const dx=cx+(x/limit)*r,dy=cy+(y/limit)*r;
|
||||||
|
ctx.beginPath();ctx.arc(dx,dy,Math.max(6,r/25),0,Math.PI*2);
|
||||||
|
const i=Math.sqrt(x*x+y*y)/limit;
|
||||||
|
const rc=Math.round(55+i*200),gc=Math.round(130-i*130),bc=Math.round(255-i*200);
|
||||||
|
ctx.fillStyle=`rgb(${rc},${gc},${bc})`;ctx.fill();
|
||||||
|
ctx.strokeStyle='rgba(255,255,255,.5)';ctx.lineWidth=1.5;ctx.stroke();
|
||||||
|
|
||||||
|
// trail
|
||||||
|
ctx.beginPath();
|
||||||
|
for(let t=0;t<8;t++){
|
||||||
|
const tx=Math.max(-limit,Math.min(limit,(ax-t*.3)/G)),ty=Math.max(-limit,Math.min(limit,(-ay-t*.3)/G));
|
||||||
|
const px=cx+(tx/limit)*r*.7,py=cy+(ty/limit)*r*.7;
|
||||||
|
t===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
|
||||||
|
}
|
||||||
|
ctx.strokeStyle='rgba(255,255,255,.04)';ctx.lineWidth=1;ctx.stroke();
|
||||||
|
|
||||||
|
// numbers
|
||||||
|
ctx.fillStyle='rgba(255,255,255,.25)';ctx.font=`${Math.max(14,W/40)|0}px sans-serif`;ctx.textAlign='center';
|
||||||
|
ctx.fillText('Lat ' + (ax/G).toFixed(2) + 'G', cx, cy-r-20);
|
||||||
|
ctx.fillText('Long ' + (az/G).toFixed(2) + 'G', cx, cy+r+30);
|
||||||
|
ctx.fillText('L', cx-r-14, cy+4);ctx.fillText('R', cx+r+14, cy+4);
|
||||||
|
ctx.fillText('Brake', cx, cy-r-10);ctx.fillText('Accel', cx, cy+r+24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||||
|
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){ax=m.data.acceleration_x||0;az=m.data.acceleration_z||0;draw()}}catch(e){}}
|
||||||
|
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||||
|
|
||||||
|
apply();window.addEventListener('resize',()=>{apply()});cn()
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "g_force",
|
||||||
|
"name": "G 力圆图",
|
||||||
|
"category": "physics",
|
||||||
|
"description": "加速度向量实时可视化,圆心偏移表示纵向/横向G力方向和大小",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"supported_games": "all",
|
||||||
|
"config": {
|
||||||
|
"icon": "🎯",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||||
|
<title>TurboSu - 手机仪表</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
|
||||||
|
font-family:'SF Pro Display',-apple-system,system-ui,sans-serif}
|
||||||
|
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||||
|
#box{position:relative;overflow:hidden}
|
||||||
|
#box.contain{max-width:100vw;max-height:100vh}
|
||||||
|
#box.cover{min-width:100vw;min-height:100vh}
|
||||||
|
#box.fill{width:100vw;height:100vh}
|
||||||
|
#content{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:min(3vh,16px);background:#0a0a0f}
|
||||||
|
|
||||||
|
.gear{font-size:min(20vw,120px);font-weight:200;line-height:1}
|
||||||
|
.rpm-num{font-size:min(10vw,56px);font-weight:300;letter-spacing:-.02em}
|
||||||
|
.rpm-unit{font-size:min(3vw,14px);color:rgba(255,255,255,.2);letter-spacing:.1em}
|
||||||
|
.rpm-bar{width:min(60vw,200px);height:6px;background:rgba(255,255,255,.06);border-radius:3px;overflow:hidden}
|
||||||
|
.rpm-fill{height:100%;border-radius:3px;transition:width .06s,background .2s}
|
||||||
|
.shift-strip{display:flex;gap:4px}
|
||||||
|
.shift-led{width:min(6vw,28px);height:min(4vw,18px);border-radius:3px;background:rgba(255,255,255,.04);transition:background .06s,box-shadow .06s}
|
||||||
|
.shift-led.on{background:#34C759;box-shadow:0 0 8px rgba(52,199,89,.4)}
|
||||||
|
.shift-led.warn{background:#FF9F0A;box-shadow:0 0 12px rgba(255,159,10,.5)}
|
||||||
|
.shift-led.red{background:#E94634;animation:pulse .3s infinite alternate}
|
||||||
|
@keyframes pulse{from{box-shadow:0 0 16px rgba(233,70,52,.6)}to{box-shadow:0 0 32px rgba(255,50,30,1)}}
|
||||||
|
|
||||||
|
.speed-num{font-size:min(8vw,48px);font-weight:300;color:rgba(255,255,255,.6)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"><div id="box" class="contain"><div id="content">
|
||||||
|
<div class="shift-strip" id="shift-row"></div>
|
||||||
|
<div class="rpm-num" id="rpm">0</div>
|
||||||
|
<div class="rpm-unit">RPM</div>
|
||||||
|
<div class="rpm-bar"><div class="rpm-fill" id="rpm-fill"></div></div>
|
||||||
|
<div class="gear" id="gear">N</div>
|
||||||
|
<div class="speed-num" id="speed">0</div>
|
||||||
|
<div class="rpm-unit">KM/H</div>
|
||||||
|
</div></div></div>
|
||||||
|
<script>
|
||||||
|
const M={aspect_ratio:"9:16",render_mode:"contain"},NL=10;
|
||||||
|
(function(){
|
||||||
|
const box=document.getElementById('box');
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||||
|
let ws,rt,leds=[];
|
||||||
|
const shiftRow=document.getElementById('shift-row');
|
||||||
|
for(let i=0;i<NL;i++){const d=document.createElement('div');d.className='shift-led';shiftRow.appendChild(d);leds.push(d)}
|
||||||
|
|
||||||
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||||
|
|
||||||
|
function apply(){
|
||||||
|
const rt=pr(M.aspect_ratio||'9:16'),md=M.render_mode||'contain';
|
||||||
|
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||||
|
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||||
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||||
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||||
|
box.style.width=cw+'px';box.style.height=ch+'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||||
|
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){
|
||||||
|
const d=m.data,rpm=d.rpm||0,max=d.max_rpm||8000,pct=rpm/max;
|
||||||
|
document.getElementById('rpm').textContent=Math.round(rpm);
|
||||||
|
const f=document.getElementById('rpm-fill');f.style.width=(pct*100)+'%';
|
||||||
|
f.style.background=pct>.9?'#E94634':pct>.7?'#FF9F0A':'#3482FF';
|
||||||
|
const g=d.gear||0;document.getElementById('gear').textContent=g===0?'R':String(g);
|
||||||
|
document.getElementById('speed').textContent=Math.round(d.speed_kmh||0);
|
||||||
|
for(let i=0;i<NL;i++){
|
||||||
|
const t=i/NL;let cls='shift-led';
|
||||||
|
if(t<=pct){
|
||||||
|
if(i>=Math.floor(NL*.85))cls+=' red';
|
||||||
|
else if(i>=Math.floor(NL*.65))cls+=' warn';
|
||||||
|
else cls+=' on';
|
||||||
|
}
|
||||||
|
leds[i].className=cls;
|
||||||
|
}
|
||||||
|
}}catch(e){}};ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||||
|
|
||||||
|
apply();window.addEventListener('resize',apply);cn()
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "phone_dash",
|
||||||
|
"name": "手机竖屏仪表",
|
||||||
|
"category": "racing",
|
||||||
|
"description": "竖屏适配,方向盘后置:转速+档位+换挡灯,9:16宽高比",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"supported_games": "all",
|
||||||
|
"config": {
|
||||||
|
"icon": "📱",
|
||||||
|
"aspect_ratio": "9:16",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||||
|
<title>TurboSu - 换挡灯</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;
|
||||||
|
font-family:'SF Pro Display',-apple-system,system-ui,sans-serif}
|
||||||
|
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||||
|
#box{position:relative;overflow:hidden}
|
||||||
|
#box.contain{max-width:100vw;max-height:100vh}
|
||||||
|
#box.cover{min-width:100vw;min-height:100vh}
|
||||||
|
#box.fill{width:100vw;height:100vh}
|
||||||
|
#content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#0a0a0f;gap:min(2vw,8px);padding:min(2vh,12px)}
|
||||||
|
.led{width:min(8vw,40px);height:min(12vw,60px);border-radius:4px;background:rgba(255,255,255,.03);transition:background .06s,box-shadow .06s}
|
||||||
|
.led.on-green{background:#34C759;box-shadow:0 0 12px rgba(52,199,89,.5)}
|
||||||
|
.led.on-yellow{background:#FF9F0A;box-shadow:0 0 16px rgba(255,159,10,.6)}
|
||||||
|
.led.on-red{background:#E94634;box-shadow:0 0 24px rgba(233,70,52,.8)}
|
||||||
|
.led.on-flash{animation:pulse .3s ease-in-out infinite alternate}
|
||||||
|
@keyframes pulse{from{opacity:.7;box-shadow:0 0 24px rgba(233,70,52,.6)}to{opacity:1;box-shadow:0 0 40px rgba(255,50,30,1)}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"><div id="box" class="contain"><div id="content" id="led-strip"></div></div></div>
|
||||||
|
<script>
|
||||||
|
const M={aspect_ratio:"16:3",render_mode:"contain"};
|
||||||
|
const N=12;
|
||||||
|
(function(){
|
||||||
|
const box=document.getElementById('box'),strip=document.getElementById('led-strip');
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||||
|
let ws,rt,leds=[];
|
||||||
|
|
||||||
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||||
|
|
||||||
|
for(let i=0;i<N;i++){const d=document.createElement('div');d.className='led';strip.appendChild(d);leds.push(d)}
|
||||||
|
|
||||||
|
function apply(){
|
||||||
|
const rt=pr(M.aspect_ratio||'16:3'),md=M.render_mode||'contain';
|
||||||
|
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||||
|
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||||
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||||
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||||
|
box.style.width=cw+'px';box.style.height=ch+'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||||
|
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){
|
||||||
|
const rpm=m.data.rpm||0,max=m.data.max_rpm||8000,pct=rpm/max;
|
||||||
|
const greenEnd=Math.floor(N*.55),yellowEnd=Math.floor(N*.8);
|
||||||
|
for(let i=0;i<N;i++){
|
||||||
|
const t=i/N,cls=['led'];
|
||||||
|
if(t<=pct){
|
||||||
|
if(i>=yellowEnd)cls.push('on-red','on-flash');
|
||||||
|
else if(i>=greenEnd)cls.push('on-yellow');
|
||||||
|
else cls.push('on-green');
|
||||||
|
}
|
||||||
|
leds[i].className=cls.join(' ');
|
||||||
|
}
|
||||||
|
}}catch(e){}};ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||||
|
|
||||||
|
apply();window.addEventListener('resize',apply);cn()
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "shift_light",
|
||||||
|
"name": "换挡指示灯",
|
||||||
|
"category": "racing",
|
||||||
|
"description": "转速接近红线区逐级亮灯闪烁提示",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"supported_games": "all",
|
||||||
|
"config": {
|
||||||
|
"icon": "💡",
|
||||||
|
"aspect_ratio": "16:3",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||||
|
<title>TurboSu - 轮胎温度</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
|
||||||
|
font-family:'SF Pro Display',-apple-system,system-ui,sans-serif}
|
||||||
|
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
|
||||||
|
#box{position:relative;overflow:hidden}
|
||||||
|
#box.contain{max-width:100vw;max-height:100vh}
|
||||||
|
#box.cover{min-width:100vw;min-height:100vh}
|
||||||
|
#box.fill{width:100vw;height:100vh}
|
||||||
|
#content{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#0a0a0f;gap:min(3vh,16px)}
|
||||||
|
.grid{display:grid;grid-template-columns:1fr 1fr;gap:min(3vw,20px);width:min(70vw,400px)}
|
||||||
|
.tire{display:flex;flex-direction:column;align-items:center;gap:8px}
|
||||||
|
.tire-box{width:min(20vw,120px);height:min(20vw,120px);border-radius:12px;background:rgba(255,255,255,.04);display:flex;flex-direction:column;align-items:center;justify-content:center;transition:background .3s}
|
||||||
|
.tire-temp{font-size:min(6vw,40px);font-weight:300;font-variant-numeric:tabular-nums}
|
||||||
|
.tire-label{font-size:min(2.5vw,14px);color:rgba(255,255,255,.3);text-transform:uppercase;letter-spacing:.1em}
|
||||||
|
.tire-status{font-size:min(2vw,11px);margin-top:2px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"><div id="box" class="contain"><div id="content">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="tire"><div class="tire-box" id="tfl"><div class="tire-temp" id="vfl">--</div><div class="tire-status" id="sfl"></div></div><div class="tire-label">FL 左前</div></div>
|
||||||
|
<div class="tire"><div class="tire-box" id="tfr"><div class="tire-temp" id="vfr">--</div><div class="tire-status" id="sfr"></div></div><div class="tire-label">FR 右前</div></div>
|
||||||
|
<div class="tire"><div class="tire-box" id="trl"><div class="tire-temp" id="vrl">--</div><div class="tire-status" id="srl"></div></div><div class="tire-label">RL 左后</div></div>
|
||||||
|
<div class="tire"><div class="tire-box" id="trr"><div class="tire-temp" id="vrr">--</div><div class="tire-status" id="srr"></div></div><div class="tire-label">RR 右后</div></div>
|
||||||
|
</div>
|
||||||
|
</div></div></div>
|
||||||
|
<script>
|
||||||
|
const M={aspect_ratio:"4:3",render_mode:"contain"};
|
||||||
|
(function(){
|
||||||
|
const box=document.getElementById('box');
|
||||||
|
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
|
||||||
|
let ws,rt;
|
||||||
|
const ids=['fl','fr','rl','rr'];
|
||||||
|
|
||||||
|
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
|
||||||
|
|
||||||
|
function apply(){
|
||||||
|
const rt=pr(M.aspect_ratio||'4:3'),md=M.render_mode||'contain';
|
||||||
|
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
|
||||||
|
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
|
||||||
|
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
|
||||||
|
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
|
||||||
|
box.style.width=cw+'px';box.style.height=ch+'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function tempColor(t){
|
||||||
|
// 0=Cold(blue) 60=Optimal(green) 100+=Overheat(red)
|
||||||
|
if(t<20)return'rgba(52,130,255,.15)';
|
||||||
|
if(t<50)return'rgba(52,200,130,.2)';
|
||||||
|
if(t<80)return'rgba(52,200,50,.25)';
|
||||||
|
if(t<100)return'rgba(255,160,20,.25)';
|
||||||
|
return'rgba(255,50,40,.3)';
|
||||||
|
}
|
||||||
|
function tempStatus(t){
|
||||||
|
if(t<20)return{text:'太冷',color:'#3482FF'};
|
||||||
|
if(t<50)return{text:'预热',color:'#34C880'};
|
||||||
|
if(t<80)return{text:'最佳',color:'#34C830'};
|
||||||
|
if(t<100)return{text:'偏高',color:'#FFA014'};
|
||||||
|
return{text:'过热',color:'#FF3228'};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
|
||||||
|
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry'){
|
||||||
|
const d=m.data,raw=d.raw||{};
|
||||||
|
// Forza parser puts tire temps in raw; alternately we can read direct fields
|
||||||
|
const temps=[raw.tire_temp_fl||0,raw.tire_temp_fr||0,raw.tire_temp_rl||0,raw.tire_temp_rr||0];
|
||||||
|
ids.forEach((id,i)=>{
|
||||||
|
const t=temps[i],el=document.getElementById('t'+id),vEl=document.getElementById('v'+id),sEl=document.getElementById('s'+id);
|
||||||
|
if(t>0){
|
||||||
|
el.style.background=tempColor(t);
|
||||||
|
vEl.textContent=Math.round(t)+'°';
|
||||||
|
const st=tempStatus(t);sEl.textContent=st.text;sEl.style.color=st.color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}catch(e){}}
|
||||||
|
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
|
||||||
|
|
||||||
|
apply();window.addEventListener('resize',apply);cn()
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"id": "tire_temp",
|
||||||
|
"name": "轮胎温度矩阵",
|
||||||
|
"category": "physics",
|
||||||
|
"description": "四轮实时温度热力图,蓝=冷 / 绿=最佳 / 红=过热",
|
||||||
|
"author": "Yei.J. (AskaEth)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"supported_games": "forza_horizon,forza_horizon_6,forza_motorsport",
|
||||||
|
"config": {
|
||||||
|
"icon": "🌡",
|
||||||
|
"aspect_ratio": "4:3",
|
||||||
|
"render_mode": "contain"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
turbosu:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "9527:9527"
|
||||||
|
- "20777:20777/udp"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./dashboards:/app/dashboards
|
||||||
|
- ./games:/app/games
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
|
||||||
|
# 📡 ACC Broadcasting API 完整规范
|
||||||
|
|
||||||
|
## 一、概述
|
||||||
|
|
||||||
|
ACC 有两种遥测数据通道:
|
||||||
|
|
||||||
|
| 通道 | 协议 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Broadcasting API** | UDP(双向) | 广播/观战、实时计时、多车数据 |
|
||||||
|
| **Shared Memory** | 共享内存(Windows) | 仪表盘、运动平台、FFB(仅玩家车辆) |
|
||||||
|
|
||||||
|
SDK 路径:`steamapps/common/Assetto Corsa Competizione/sdk/broadcasting/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、启用 Broadcasting
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
编辑 `文档/Assetto Corsa Competizione/Config/broadcasting.json`(**必须 UTF-16LE BOM 编码**):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updListenerPort": 9000,
|
||||||
|
"connectionPassword": "asd",
|
||||||
|
"commandPassword": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `updListenerPort` | ACC 监听的 UDP 端口(默认 9000) |
|
||||||
|
| `connectionPassword` | 客户端连接密码 |
|
||||||
|
| `commandPassword` | 控制命令密码(留空则所有已连接客户端都可发送命令) |
|
||||||
|
|
||||||
|
### 测试客户端
|
||||||
|
|
||||||
|
运行 `sdk/broadcasting/Testclient/ksBroadcastingTestClient.exe`,输入端口和密码后点击 Connect 即可验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、协议规范
|
||||||
|
|
||||||
|
### 基本规则
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **协议版本**:`BROADCASTING_PROTOCOL_VERSION = 2`
|
||||||
|
- **字符串编码**:UTF-8,前缀 `uint16` 长度字段
|
||||||
|
- **通信流程**:客户端 → ACC 发起注册 → ACC 回传数据流
|
||||||
|
|
||||||
|
### 数据序列化规则
|
||||||
|
|
||||||
|
| 类型 | 字节数 |
|
||||||
|
|---|---|
|
||||||
|
| `byte` / `int8` | 1 |
|
||||||
|
| `uint16` / `int16` | 2 |
|
||||||
|
| `int32` / `uint32` | 4 |
|
||||||
|
| `float32` | 4 |
|
||||||
|
| `string` | 2(长度)+ N(UTF-8 内容) |
|
||||||
|
|
||||||
|
### Lap 结构体(通用圈速格式)
|
||||||
|
|
||||||
|
```
|
||||||
|
int32 LaptimeMS // 圈速(ms),Int32.MaxValue = 无圈速
|
||||||
|
uint16 CarIndex // 车辆索引
|
||||||
|
uint16 DriverIndex // 车手索引
|
||||||
|
uint8 SplitCount // 分段数(通常 3)
|
||||||
|
int32[] Splits // 各分段成绩(ms),Int32.MaxValue = 无效
|
||||||
|
uint8 IsInvalid // 0=有效, 1=无效
|
||||||
|
uint8 IsValidForBest // 0=不可作最佳圈, 1=可作最佳圈
|
||||||
|
uint8 IsOutLap // 0=非出场圈, 1=出场圈
|
||||||
|
uint8 IsInLap // 0=非回场圈, 1=回场圈
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、出站消息(客户端 → ACC)
|
||||||
|
|
||||||
|
所有消息首字节为消息类型。
|
||||||
|
|
||||||
|
| 类型 ID | 消息名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` | **REGISTER_COMMAND_APPLICATION** | 注册连接(必须最先发送) |
|
||||||
|
| `9` | UNREGISTER_COMMAND_APPLICATION | 取消注册 |
|
||||||
|
| `10` | **REQUEST_ENTRY_LIST** | 请求参赛者列表 |
|
||||||
|
| `11` | **REQUEST_TRACK_DATA** | 请求赛道数据 |
|
||||||
|
| `49` | CHANGE_HUD_PAGE | 切换 HUD 页面 |
|
||||||
|
| `50` | **CHANGE_FOCUS** | 切换焦点车辆/摄像机 |
|
||||||
|
| `51` | **INSTANT_REPLAY_REQUEST** | 请求即时回放 |
|
||||||
|
| `52` | PLAY_MANUAL_REPLAY_HIGHLIGHT | (预留) |
|
||||||
|
| `60` | SAVE_MANUAL_REPLAY_HIGHLIGHT | (预留) |
|
||||||
|
|
||||||
|
### 1. REGISTER_COMMAND_APPLICATION(ID=1)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 1 // 消息类型
|
||||||
|
byte BROADCASTING_PROTOCOL_VERSION // = 2
|
||||||
|
string DisplayName // 显示名称
|
||||||
|
string ConnectionPassword // 连接密码(匹配 broadcasting.json)
|
||||||
|
int32 RealtimeUpdateInterval // 实时更新间隔(ms),建议 250
|
||||||
|
string CommandPassword // 命令密码
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. CHANGE_FOCUS(ID=50)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 50 // 消息类型
|
||||||
|
int32 ConnectionId // 连接 ID
|
||||||
|
uint8 HasCarChange // 0=不变, 1=切换车辆
|
||||||
|
uint16 CarIndex // 目标车辆索引(仅 HasCarChange=1)
|
||||||
|
uint8 HasCameraChange // 0=不变, 1=切换摄像机
|
||||||
|
string CameraSet // 摄像机集合名
|
||||||
|
string Camera // 摄像机名
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. INSTANT_REPLAY_REQUEST(ID=51)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 51 // 消息类型
|
||||||
|
int32 ConnectionId
|
||||||
|
float32 StartSessionTime // 回放起点(会话时间,ms)
|
||||||
|
float32 DurationMS // 回放时长(ms)
|
||||||
|
int32 InitialFocusedCar // 初始焦点车辆(-1 = 保持当前)
|
||||||
|
string InitialCameraSet
|
||||||
|
string InitialCamera
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. REQUEST_ENTRY_LIST(ID=10)/ REQUEST_TRACK_DATA(ID=11)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 10 或 11
|
||||||
|
int32 ConnectionId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、入站消息(ACC → 客户端)
|
||||||
|
|
||||||
|
| 类型 ID | 消息名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` | **REGISTRATION_RESULT** | 注册结果 |
|
||||||
|
| `2` | **REALTIME_UPDATE** | 会话实时信息(全局) |
|
||||||
|
| `3` | **REALTIME_CAR_UPDATE** | 车辆实时信息(每车) |
|
||||||
|
| `4` | **ENTRY_LIST** | 参赛者索引列表 |
|
||||||
|
| `5` | **TRACK_DATA** | 赛道和摄像机数据 |
|
||||||
|
| `6` | **ENTRY_LIST_CAR** | 单辆车详细信息 |
|
||||||
|
| `7` | **BROADCASTING_EVENT** | 广播事件 |
|
||||||
|
|
||||||
|
### 1. REGISTRATION_RESULT(ID=1)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 1
|
||||||
|
int32 ConnectionId // 分配的连接 ID(后续请求必须携带)
|
||||||
|
uint8 ConnectionSuccess // 0=失败, 1=成功
|
||||||
|
uint8 IsReadOnly // 0=可读写(即拥有 commandPassword)
|
||||||
|
string ErrorMessage // 错误信息
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后应立即发送 `REQUEST_ENTRY_LIST` 和 `REQUEST_TRACK_DATA`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. REALTIME_UPDATE(ID=2)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 2
|
||||||
|
uint16 EventIndex // 赛事索引
|
||||||
|
uint16 SessionIndex // 会话索引
|
||||||
|
uint8 SessionType // 见下表
|
||||||
|
uint8 Phase // 会话阶段(见下表)
|
||||||
|
float32 SessionTime // 会话已过时间 (ms)
|
||||||
|
float32 SessionEndTime // 会话剩余时间 (ms)
|
||||||
|
int32 FocusedCarIndex // 当前焦点车辆
|
||||||
|
string ActiveCameraSet // 当前摄像机集合
|
||||||
|
string ActiveCamera // 当前摄像机
|
||||||
|
string CurrentHUDPage // 当前 HUD 页面
|
||||||
|
uint8 IsReplayPlaying // 是否回放中
|
||||||
|
// 以下仅 IsReplayPlaying=1 时存在:
|
||||||
|
float32 ReplaySessionTime // 回放中的会话时间
|
||||||
|
float32 ReplayRemainingTime // 回放剩余时间
|
||||||
|
int32 ReplayFocusedCar
|
||||||
|
// 始终存在:
|
||||||
|
uint16 TimeOfDay // 时间 hh*100+mm
|
||||||
|
uint8 AmbientTemp // 环境温度 (°C)
|
||||||
|
uint8 TrackTemp // 赛道温度 (°C)
|
||||||
|
uint8 Clouds // 云量(÷10,即 0.0~1.0)
|
||||||
|
uint8 RainLevel // 降雨量(÷10)
|
||||||
|
uint8 Wetness // 路面湿度(÷10)
|
||||||
|
Lap BestSessionLap // 当前会话最佳圈速
|
||||||
|
```
|
||||||
|
|
||||||
|
#### RaceSessionType 枚举
|
||||||
|
|
||||||
|
| 值 | 类型 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Practice(练习) |
|
||||||
|
| 4 | Qualifying(排位赛) |
|
||||||
|
| 9 | Superpole(超级杆位) |
|
||||||
|
| 10 | Race(正赛) |
|
||||||
|
| 11 | Hotlap(单圈冲刺) |
|
||||||
|
| 12 | Hotstint(阶段冲刺) |
|
||||||
|
| 13 | HotlapSuperpole |
|
||||||
|
| 14 | Replay(回放) |
|
||||||
|
|
||||||
|
#### SessionPhase 枚举
|
||||||
|
|
||||||
|
| 值 | 阶段 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | NONE |
|
||||||
|
| 1 | Starting |
|
||||||
|
| 2 | PreFormation |
|
||||||
|
| 3 | FormationLap(编队圈) |
|
||||||
|
| 4 | PreSession |
|
||||||
|
| 5 | **Session**(绿灯起跑后) |
|
||||||
|
| 6 | SessionOver |
|
||||||
|
| 7 | PostSession |
|
||||||
|
| 8 | ResultUI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. REALTIME_CAR_UPDATE(ID=3)
|
||||||
|
|
||||||
|
**最重要的数据包**—每辆车独立发送。
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 3
|
||||||
|
uint16 CarIndex // 车辆索引(对应 EntryList)
|
||||||
|
uint16 DriverIndex // 当前车手索引
|
||||||
|
uint8 DriverCount // 该车车手总数
|
||||||
|
int8 Gear // -1=R, 0=N, 1~6...
|
||||||
|
float32 WorldPosX // 世界坐标 X(始终为 0)
|
||||||
|
float32 WorldPosY // 世界坐标 Y(始终为 0)
|
||||||
|
float32 Yaw // 偏航角(始终为 0)
|
||||||
|
uint8 CarLocation // 位置枚举(见下)
|
||||||
|
uint16 Kmh // 速度 (km/h)
|
||||||
|
uint16 Position // 排名(1-based)
|
||||||
|
uint16 CupPosition // 组别排名(1-based)
|
||||||
|
uint16 TrackPosition // 赛道位置序号(始终为 0)
|
||||||
|
float32 SplinePosition // 赛道位置 0.0~1.0
|
||||||
|
uint16 Laps // 已完成圈数
|
||||||
|
int32 Delta // 与最佳圈速的实时差距 (ms)
|
||||||
|
Lap BestSessionLap // 该车最佳圈速
|
||||||
|
Lap LastLap // 上一圈
|
||||||
|
Lap CurrentLap // 当前圈(时间持续更新,分段不填充)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CarLocation 枚举
|
||||||
|
|
||||||
|
| 值 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | NONE |
|
||||||
|
| 1 | **Track**(赛道上) |
|
||||||
|
| 2 | Pitlane(维修区通道) |
|
||||||
|
| 3 | PitEntry(进站入口) |
|
||||||
|
| 4 | PitExit(出站出口) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. ENTRY_LIST(ID=4)+ ENTRY_LIST_CAR(ID=6)
|
||||||
|
|
||||||
|
**两步接收**:先收索引列表,再逐车收详情。
|
||||||
|
|
||||||
|
#### ENTRY_LIST 结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 4
|
||||||
|
int32 ConnectionId
|
||||||
|
uint16 CarEntryCount
|
||||||
|
uint16[] CarIndexes // 车辆索引数组
|
||||||
|
uint16 DriverEntryCount
|
||||||
|
uint16[] DriverIndexes // 保留字段
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ENTRY_LIST_CAR 结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 6
|
||||||
|
uint16 CarIndex // 对应 ENTRY_LIST 中的索引
|
||||||
|
uint8 CarModelType // 车型(见下)
|
||||||
|
string TeamName // 车队名
|
||||||
|
int32 RaceNumber // 车号
|
||||||
|
string TeamCarName // 车型名称
|
||||||
|
string DisplayName // 显示名
|
||||||
|
uint8 CupCategory // 组别:0=Overall/Pro, 1=ProAm, 2=Am, 3=Silver, 4=National
|
||||||
|
uint8 CurrentDriverId // 当前车手 ID
|
||||||
|
uint16 Nationality // 国籍(见附录)
|
||||||
|
uint8 DriversOnCarCount // 该车手数量
|
||||||
|
// 每个车手:
|
||||||
|
uint16 DriverIndex
|
||||||
|
uint8 HasDriverInfo // 0/1
|
||||||
|
string FirstName
|
||||||
|
string LastName
|
||||||
|
string Nickname
|
||||||
|
string ShortName // 缩写(如 "VER")
|
||||||
|
uint8 Category // 3=Platinum, 2=Gold, 1=Silver, 0=Bronze
|
||||||
|
uint16 Nationality
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CarModelType(部分)
|
||||||
|
|
||||||
|
| 值 | 车型 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Mercedes-AMG GT3 |
|
||||||
|
| 2 | Ferrari 488 GT3 |
|
||||||
|
| 15 | Lexus RC F GT3 |
|
||||||
|
| 16 | Lamborghini Huracán GT3 |
|
||||||
|
| 19 | Audi R8 LMS GT3 |
|
||||||
|
| 20 | Aston Martin Vantage GT3 |
|
||||||
|
| 23 | Porsche 911 GT3 R |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. TRACK_DATA(ID=5)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 5
|
||||||
|
int32 ConnectionId
|
||||||
|
string TrackName // 赛道名
|
||||||
|
int32 TrackId // 赛道 ID(见下)
|
||||||
|
int32 TrackMeters // 赛道长度 (m)
|
||||||
|
uint8 CameraSetCount
|
||||||
|
// 每个摄像机集合:
|
||||||
|
string CameraSetName
|
||||||
|
uint8 CameraCount
|
||||||
|
string[] CameraNames // 逐个摄像机名
|
||||||
|
uint8 HUDPageCount
|
||||||
|
string[] HUDPageNames
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TrackId(部分)
|
||||||
|
|
||||||
|
| ID | 赛道 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Brands Hatch |
|
||||||
|
| 2 | Spa-Francorchamps |
|
||||||
|
| 3 | Monza |
|
||||||
|
| 4 | Misano |
|
||||||
|
| 5 | Paul Ricard |
|
||||||
|
| 6 | Silverstone |
|
||||||
|
| 7 | Hungaroring |
|
||||||
|
| 8 | Nürburgring |
|
||||||
|
| 9 | Barcelona |
|
||||||
|
| 10 | Zolder |
|
||||||
|
| 11 | Zandvoort |
|
||||||
|
| 13 | Mount Panorama (Bathurst) |
|
||||||
|
| 14 | Laguna Seca |
|
||||||
|
| 15 | Suzuka |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. BROADCASTING_EVENT(ID=7)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 7
|
||||||
|
uint8 Type // 事件类型
|
||||||
|
string Msg // 事件消息(常为圈速字符串)
|
||||||
|
int32 TimeMs // 自连接建立以来的时间 (ms)
|
||||||
|
int32 CarId // 相关车辆 ID
|
||||||
|
```
|
||||||
|
|
||||||
|
| 事件 Type | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 5 | LapCompleted(完成一圈) |
|
||||||
|
| 6 | BestSessionLap(全场最佳圈) |
|
||||||
|
| 7 | BestPersonalLap(个人最佳圈) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Shared Memory API(补充)
|
||||||
|
|
||||||
|
用于本地仪表盘/运动平台,仅含玩家车辆数据。
|
||||||
|
|
||||||
|
### 三个内存页
|
||||||
|
|
||||||
|
| 页 | 大小 | 更新频率 | 内容 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SPageFilePhysics** | ~800 B | 物理步进 | 速度、油门、刹车、G力、胎温等 |
|
||||||
|
| **SPageFileGraphic** | ~852 B | 图形帧率 | 圈速、排名、旗语、天气、油耗等 |
|
||||||
|
| **SPageFileStatic** | ~688 B | 固定 | 车辆名、赛道、最大RPM等 |
|
||||||
|
|
||||||
|
### Physics 关键字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `gas` | float | 油门 0.0~1.0 |
|
||||||
|
| `brake` | float | 刹车 0.0~1.0 |
|
||||||
|
| `gear` | int | 档位 |
|
||||||
|
| `rpm` | int | 引擎转速 |
|
||||||
|
| `speedKmh` | float | 速度 |
|
||||||
|
| `steerAngle` | float | 转向角 |
|
||||||
|
| `wheelPressure[4]` | float | 胎压 |
|
||||||
|
| `tyreCoreTemp[4]` | float | 轮胎内核温度 |
|
||||||
|
| `brakeTemp[4]` | float | 刹车温度 |
|
||||||
|
| `carDamage` | struct | 车辆损伤 |
|
||||||
|
| `fuel` | float | 油量 (L) |
|
||||||
|
| `abs` | float | ABS 作用量 |
|
||||||
|
| `tc` | float | TC 作用量 |
|
||||||
|
| `localVelocity` | Vector3f | 局部速度 |
|
||||||
|
| `localAngularVel` | Vector3f | 局部角速度 |
|
||||||
|
| `gForce` | Vector3f | G力 |
|
||||||
|
|
||||||
|
### Graphics 关键字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | enum | AC_OFF=0, AC_LIVE=1 等 |
|
||||||
|
| `sessionType` | enum | 同 Broadcasting |
|
||||||
|
| `currentTime` | int | 当前圈速 (ms) |
|
||||||
|
| `lastTime` | int | 上一圈 (ms) |
|
||||||
|
| `bestTime` | int | 最佳圈 (ms) |
|
||||||
|
| `completedLaps` | int | 已完成圈数 |
|
||||||
|
| `position` | int | 排名 |
|
||||||
|
| `normalizedCarPosition` | float | 赛道位置 0.0~1.0 |
|
||||||
|
| `isInPit` | bool | 是否在维修区 |
|
||||||
|
| `flag` | enum | 旗语 |
|
||||||
|
| `fuelPerLap` | float | 每圈油耗 |
|
||||||
|
| `gapAhead` | int | 与前车差距 (ms) |
|
||||||
|
| `gapBehind` | int | 与后车差距 (ms) |
|
||||||
|
| `trackStatus` | string | Green/Fast/Optimum/Greasy/Damp/Wet/Flooded |
|
||||||
|
| `rainIntensity` | enum | 降雨强度 |
|
||||||
|
| `globalYellow` | bool | 黄旗 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键注意事项
|
||||||
|
|
||||||
|
1. **WorldPosX/Y 和 Yaw**:Broadcasting API 中始终为 0(出于反作弊考虑),需要全车坐标请用 Shared Memory
|
||||||
|
2. **EntryList**:在首次连接时发送一次,新会话开始时不自动重发
|
||||||
|
3. **TrackData**:同样只在连接时发送一次,新会话不重发
|
||||||
|
4. **SplinePosition**:0.0~1.0 表示车辆在赛道上的归一化位置(维修区也有效)
|
||||||
|
5. **Lap.Splits**:Int32.MaxValue 表示无效分段,超出分段数返回 0
|
||||||
|
6. **CarUpdate 中 Gear**:传输值 - 2 = 实际档位(传输 1 = -1/R,传输 2 = 0/N,传输 3 = 1 档...)
|
||||||
|
7. **broadcasting.json 编码**:必须是 UTF-16LE with BOM,否则 ACC 无法读取
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
|
||||||
|
## 🏎️ F1 24 UDP Telemetry 规范总览
|
||||||
|
|
||||||
|
### 📌 官方文档
|
||||||
|
- **官方规范 Word 文档**:[EA Forums — Data Output from F1 24 v27.2x.docx](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-24-general-discussion-en/4624/1/Data%20Output%20from%20F1%2024%20v27.2x.docx)
|
||||||
|
- **GitHub 开源解析库参考**:https://github.com/MacManley/f1-24-udp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 游戏设置
|
||||||
|
|
||||||
|
在游戏中启用 UDP 遥测:**Game Options → Settings → Telemetry Settings**
|
||||||
|
|
||||||
|
| 设置项 | 推荐值 |
|
||||||
|
|---|---|
|
||||||
|
| `UDP Telemetry` | On |
|
||||||
|
| `UDP Broadcast Mode` | Off(多设备时可开启) |
|
||||||
|
| `UDP IP Address` | 接收端 IP(本机用 `127.0.0.1`) |
|
||||||
|
| `Port` | `20777`(默认) |
|
||||||
|
| `UDP Send Rate` | `20Hz`(默认,最高 60Hz) |
|
||||||
|
| `UDP Format` | `2024` |
|
||||||
|
|
||||||
|
> ⚠️ 注意:游戏仅在玩家进入 **计时圈(timed lap)** 后才开始发送有效遥测数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 数据编码
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **对齐**:packed(无填充)
|
||||||
|
- **最大车辆数**:22
|
||||||
|
|
||||||
|
### 数据类型
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `uint8` / `int8` | 无符号/有符号 8-bit |
|
||||||
|
| `uint16` / `int16` | 无符号/有符号 16-bit |
|
||||||
|
| `uint32` | 无符号 32-bit |
|
||||||
|
| `uint64` | 无符号 64-bit |
|
||||||
|
| `float` | 32-bit 浮点 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 包头结构(PacketHeader)
|
||||||
|
|
||||||
|
**大小:29 bytes**
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PacketHeader {
|
||||||
|
uint16 m_packetFormat; // 2024
|
||||||
|
uint8 m_gameYear; // 年份后两位,如 24
|
||||||
|
uint8 m_gameMajorVersion; // 主版本号 "X.00"
|
||||||
|
uint8 m_gameMinorVersion; // 次版本号 "1.XX"
|
||||||
|
uint8 m_packetVersion; // 包类型版本号,从 1 开始
|
||||||
|
uint8 m_packetId; // 包类型 ID(见下表)
|
||||||
|
uint64 m_sessionUID; // 会话唯一 ID
|
||||||
|
float m_sessionTime; // 会话时间戳
|
||||||
|
uint32 m_frameIdentifier; // 帧标识符
|
||||||
|
uint32 m_overallFrameIdentifier; // 全局帧标识符(Flashback 不回退)
|
||||||
|
uint8 m_playerCarIndex; // 玩家车辆索引
|
||||||
|
uint8 m_secondaryPlayerCarIndex; // 分屏第二玩家索引(255 = 无)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆔 包类型 ID 汇总
|
||||||
|
|
||||||
|
| ID | 包名称 | 说明 | 频率 | 大小 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0 | **Motion** | 所有车辆物理运动数据 | 菜单设定频率 | 1349 B |
|
||||||
|
| 1 | **Session** | 会话信息(赛道、剩余时间) | 2/秒 | 753 B |
|
||||||
|
| 2 | **Lap Data** | 所有车辆圈速数据 | 菜单设定频率 | 1285 B |
|
||||||
|
| 3 | **Event** | 会话事件 | 事件触发时 | 45 B |
|
||||||
|
| 4 | **Participants** | 参与者列表 | 每 5 秒 | 1350 B |
|
||||||
|
| 5 | **Car Setups** | 车辆调校参数 | 2/秒 | 1133 B |
|
||||||
|
| 6 | **Car Telemetry** | 遥测数据(速度/油门/DRS等) | 菜单设定频率 | 1352 B |
|
||||||
|
| 7 | **Car Status** | 车辆状态(燃油/ERS等) | 菜单设定频率 | 1239 B |
|
||||||
|
| 8 | **Final Classification** | 最终排名 | 结果界面每 5 秒 | 1020 B |
|
||||||
|
| 9 | **Lobby Info** | 多人游戏大厅信息 | 大厅中 2/秒 | 1306 B |
|
||||||
|
| 10 | **Car Damage** | 车辆损伤数据 | 10/秒 | 953 B |
|
||||||
|
| 11 | **Session History** | 圈速和轮胎历史 | 20/秒(轮询车辆) | 1460 B |
|
||||||
|
| 12 | **Tyre Sets** | 轮胎组详细数据 | 2/秒 | 231 B |
|
||||||
|
| 13 | **Motion Ex** | 玩家车辆扩展运动数据 | 菜单设定频率 | 237 B |
|
||||||
|
| 14 | **Time Trial** | 计时赛专用数据 | 1/秒(仅TT) | 101 B |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 各包数据结构详解
|
||||||
|
|
||||||
|
### 1. Motion Packet(ID=0)
|
||||||
|
包含所有 22 辆车的物理数据。
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct CarMotionData {
|
||||||
|
float m_worldPositionX, m_worldPositionY, m_worldPositionZ; // 世界位置 (m)
|
||||||
|
float m_worldVelocityX, m_worldVelocityY, m_worldVelocityZ; // 世界速度 (m/s)
|
||||||
|
int16 m_worldForwardDirX, m_worldForwardDirY, m_worldForwardDirZ; // 前向向量(÷32767 归一化)
|
||||||
|
int16 m_worldRightDirX, m_worldRightDirY, m_worldRightDirZ; // 右向向量
|
||||||
|
float m_gForceLateral, m_gForceLongitudinal, m_gForceVertical; // G 力分量
|
||||||
|
float m_yaw, m_pitch, m_roll; // 角度 (rad)
|
||||||
|
};
|
||||||
|
// PacketMotionData: PacketHeader + CarMotionData[22]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Session Packet(ID=1)
|
||||||
|
包含当前会话完整信息,如天气、温度、赛道、规则、辅助设置等。
|
||||||
|
|
||||||
|
关键字段:
|
||||||
|
- `m_weather`: 0=晴, 1=少云, 2=阴, 3=小雨, 4=大雨, 5=暴雨
|
||||||
|
- `m_trackId`: 赛道 ID(-1=未知,见附录)
|
||||||
|
- `m_sessionType`: 会话类型
|
||||||
|
- `m_formula`: 0=F1 Modern, 1=F1 Classic, 2=F2, 3=F1 Generic, 4=Beta, 6=Esports, 8=F1 World, 9=F1 Elimination
|
||||||
|
- `m_safetyCarStatus`: 0=无, 1=实体安全车, 2=虚拟安全车, 3=编队圈
|
||||||
|
- `m_weatherForecastSamples[64]`: 天气预测(最多 64 样本,适配冲刺赛周末)
|
||||||
|
- `m_weekendStructure[12]`: 周末赛程结构
|
||||||
|
- `m_sector2LapDistanceStart` / `m_sector3LapDistanceStart`: 赛段起点距离
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Lap Data Packet(ID=2)
|
||||||
|
```c
|
||||||
|
struct LapData {
|
||||||
|
uint32 m_lastLapTimeInMS; // 上一圈时间 (ms)
|
||||||
|
uint32 m_currentLapTimeInMS; // 当前圈耗时 (ms)
|
||||||
|
uint16 m_sector1TimeMSPart; // S1 毫秒部分
|
||||||
|
uint8 m_sector1TimeMinutesPart; // S1 分钟部分
|
||||||
|
uint16 m_sector2TimeMSPart; // S2 毫秒部分
|
||||||
|
uint8 m_sector2TimeMinutesPart; // S2 分钟部分
|
||||||
|
uint16 m_deltaToCarInFrontMSPart; // 与前车差距毫秒
|
||||||
|
uint8 m_deltaToCarInFrontMinutesPart; // 与前车差距分钟
|
||||||
|
uint16 m_deltaToRaceLeaderMSPart; // 与领跑者差距毫秒
|
||||||
|
uint8 m_deltaToRaceLeaderMinutesPart; // 与领跑者差距分钟
|
||||||
|
float m_lapDistance; // 当前圈已跑距离 (m)
|
||||||
|
float m_totalDistance; // 总行驶距离 (m)
|
||||||
|
float m_safetyCarDelta; // 安全车时间差 (s)
|
||||||
|
uint8 m_carPosition; // 赛道排名
|
||||||
|
uint8 m_currentLapNum; // 当前圈数
|
||||||
|
uint8 m_pitStatus; // 0=无, 1=进站中, 2=维修区
|
||||||
|
uint8 m_numPitStops; // 已进站次数
|
||||||
|
uint8 m_sector; // 0=S1, 1=S2, 2=S3
|
||||||
|
uint8 m_currentLapInvalid; // 0=有效, 1=无效
|
||||||
|
uint8 m_penalties; // 累计罚时 (s)
|
||||||
|
uint8 m_driverStatus; // 0=车库, 1=飞行圈, 2=回场圈, 3=出场圈, 4=赛道上
|
||||||
|
uint8 m_resultStatus; // 0-7 (invalid/active/finished/DNF/DSQ等)
|
||||||
|
float m_speedTrapFastestSpeed; // 速度陷阱最快速度 (km/h)
|
||||||
|
// ... 等
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Event Packet(ID=3)
|
||||||
|
事件代码:
|
||||||
|
|
||||||
|
| 代码 | 事件 | 代码 | 事件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SSTA` | 会话开始 | `SEND` | 会话结束 |
|
||||||
|
| `FTLP` | 最快圈速 | `RTMT` | 退赛 |
|
||||||
|
| `DRSE` | DRS 启用 | `DRSD` | DRS 禁用 |
|
||||||
|
| `TMPT` | 队友进站 | `CHQF` | 方格旗 |
|
||||||
|
| `RCWN` | 冠军产生 | `PENA` | 处罚发出 |
|
||||||
|
| `SPTP` | 速度陷阱触发 | `STLG` | 发车灯 |
|
||||||
|
| `LGOT` | 灯灭起跑 | `DTSV` | Drive-through 已执行 |
|
||||||
|
| `SGSV` | Stop-Go 已执行 | `FLBK` | Flashback |
|
||||||
|
| `BUTN` | 按钮状态 | `RDFL` | 红旗 |
|
||||||
|
| `OVTK` | 超车 | `SCAR` | 安全车事件 |
|
||||||
|
| `COLL` | 碰撞事件 | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Car Telemetry Packet(ID=6)
|
||||||
|
```c
|
||||||
|
struct CarTelemetryData {
|
||||||
|
uint16 m_speed; // 速度 (km/h)
|
||||||
|
float m_throttle; // 油门 (0.0~1.0)
|
||||||
|
float m_steer; // 转向 (-1.0~1.0)
|
||||||
|
float m_brake; // 刹车 (0.0~1.0)
|
||||||
|
uint8 m_clutch; // 离合 (0~100)
|
||||||
|
int8 m_gear; // 档位 (1-8, N=0, R=-1)
|
||||||
|
uint16 m_engineRPM; // 引擎转速
|
||||||
|
uint8 m_drs; // DRS: 0=关, 1=开
|
||||||
|
uint8 m_revLightsPercent; // 转速灯百分比
|
||||||
|
uint16 m_revLightsBitValue; // 转速灯位值
|
||||||
|
uint16 m_brakesTemperature[4]; // 刹车温度 (°C)
|
||||||
|
uint8 m_tyresSurfaceTemperature[4]; // 胎面温度 (°C)
|
||||||
|
uint8 m_tyresInnerTemperature[4]; // 胎内温度 (°C)
|
||||||
|
uint16 m_engineTemperature; // 引擎温度 (°C)
|
||||||
|
float m_tyresPressure[4]; // 胎压 (PSI)
|
||||||
|
uint8 m_surfaceType[4]; // 路面类型
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Car Status Packet(ID=7)
|
||||||
|
```c
|
||||||
|
struct CarStatusData {
|
||||||
|
uint8 m_tractionControl; // 0=关, 1=中, 2=全
|
||||||
|
uint8 m_antiLockBrakes; // 0=关, 1=开
|
||||||
|
uint8 m_fuelMix; // 0=Lean, 1=Standard, 2=Rich, 3=Max
|
||||||
|
uint8 m_frontBrakeBias; // 前刹车比 (%)
|
||||||
|
float m_fuelInTank; // 燃油量 (kg)
|
||||||
|
float m_fuelCapacity; // 油箱容量
|
||||||
|
float m_fuelRemainingLaps; // 剩余油量可跑圈数
|
||||||
|
uint8 m_drsAllowed; // DRS 是否允许
|
||||||
|
uint16 m_drsActivationDistance; // DRS 激活距离 (m)
|
||||||
|
uint8 m_actualTyreCompound; // 实际轮胎配方 (16=C5, 17=C4, 18=C3, 19=C2, 20=C1, 21=C0, 7=半雨, 8=全雨)
|
||||||
|
uint8 m_visualTyreCompound; // 视觉轮胎配方 (16=软, 17=中, 18=硬)
|
||||||
|
uint8 m_tyresAgeLaps; // 轮胎使用圈数
|
||||||
|
int8 m_vehicleFiaFlags; // FIA 旗帜 (-1=未知, 0=无, 1=绿, 2=蓝, 3=黄)
|
||||||
|
float m_enginePowerICE; // 内燃机功率 (W)
|
||||||
|
float m_enginePowerMGUK; // MGU-K 功率 (W)
|
||||||
|
float m_ersStoreEnergy; // ERS 储能 (J)
|
||||||
|
uint8 m_ersDeployMode; // 0=无, 1=Medium, 2=Hotlap, 3=Overtake
|
||||||
|
float m_ersHarvestedThisLapMGUK; // MGU-K 回收能量
|
||||||
|
float m_ersHarvestedThisLapMGUH; // MGU-H 回收能量
|
||||||
|
float m_ersDeployedThisLap; // 已使用 ERS 能量
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Car Damage Packet(ID=10)
|
||||||
|
```c
|
||||||
|
struct CarDamageData {
|
||||||
|
float m_tyresWear[4]; // 轮胎磨损 (%)
|
||||||
|
uint8 m_tyresDamage[4]; // 轮胎损伤 (%)
|
||||||
|
uint8 m_brakesDamage[4]; // 刹车损伤 (%)
|
||||||
|
uint8 m_frontLeftWingDamage; // 前左翼损伤 (%)
|
||||||
|
uint8 m_frontRightWingDamage; // 前右翼损伤 (%)
|
||||||
|
uint8 m_rearWingDamage; // 尾翼损伤 (%)
|
||||||
|
uint8 m_floorDamage; // 底板损伤 (%)
|
||||||
|
uint8 m_diffuserDamage; // 扩散器损伤 (%)
|
||||||
|
uint8 m_sidepodDamage; // 侧箱损伤 (%)
|
||||||
|
uint8 m_drsFault; // DRS 故障 (0/1)
|
||||||
|
uint8 m_ersFault; // ERS 故障 (0/1)
|
||||||
|
uint8 m_gearBoxDamage; // 变速箱损伤 (%)
|
||||||
|
uint8 m_engineDamage; // 引擎损伤 (%)
|
||||||
|
uint8 m_engineMGUHWear; // MGU-H 磨损 (%)
|
||||||
|
uint8 m_engineESWear; // ES 磨损 (%)
|
||||||
|
uint8 m_engineCEWear; // CE 磨损 (%)
|
||||||
|
uint8 m_engineICEWear; // ICE 磨损 (%)
|
||||||
|
uint8 m_engineMGUKWear; // MGU-K 磨损 (%)
|
||||||
|
uint8 m_engineTCWear; // TC 磨损 (%)
|
||||||
|
uint8 m_engineBlown; // 引擎爆缸 (0/1)
|
||||||
|
uint8 m_engineSeized; // 引擎卡死 (0/1)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Motion Ex Packet(ID=13)— 运动平台关键包
|
||||||
|
|
||||||
|
专为 **运动模拟平台(D-BOX 等)** 设计,包含更多细节:
|
||||||
|
|
||||||
|
```c
|
||||||
|
float m_suspensionPosition[4]; // 悬挂位置 [RL, RR, FL, FR]
|
||||||
|
float m_suspensionVelocity[4]; // 悬挂速度
|
||||||
|
float m_suspensionAcceleration[4]; // 悬挂加速度
|
||||||
|
float m_wheelSpeed[4]; // 各轮速度
|
||||||
|
float m_wheelSlipRatio[4]; // 滑移率
|
||||||
|
float m_wheelSlipAngle[4]; // 滑移角
|
||||||
|
float m_wheelLatForce[4]; // 横向力
|
||||||
|
float m_wheelLongForce[4]; // 纵向力
|
||||||
|
float m_wheelVertForce[4]; // 垂向力
|
||||||
|
float m_heightOfCOGAboveGround; // 重心高度
|
||||||
|
float m_localVelocityX/Y/Z; // 局部速度
|
||||||
|
float m_angularVelocityX/Y/Z; // 角速度 (rad/s)
|
||||||
|
float m_angularAccelerationX/Y/Z; // 角加速度 (rad/s²)
|
||||||
|
float m_frontWheelsAngle; // 前轮转角 (rad)
|
||||||
|
float m_frontAeroHeight; // 前底盘高度
|
||||||
|
float m_rearAeroHeight; // 后底盘高度
|
||||||
|
float m_frontRollAngle; // 前悬挂侧倾角
|
||||||
|
float m_rearRollAngle; // 后悬挂侧倾角
|
||||||
|
float m_chassisYaw; // 底盘偏航角 (rad) — 可用于判断侧滑
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 其余包简介
|
||||||
|
|
||||||
|
| 包 | 关键内容 |
|
||||||
|
|---|---|
|
||||||
|
| **Final Classification (8)** | 最终排名、圈数、积分、最佳圈速、轮胎使用记录 |
|
||||||
|
| **Lobby Info (9)** | 多人游戏大厅的玩家信息 |
|
||||||
|
| **Session History (11)** | 每辆车的全部圈速历史 + 轮胎使用历史(最多 100 圈) |
|
||||||
|
| **Tyre Sets (12)** | 轮胎组详情:配方、磨损度、可用性、圈速差等 |
|
||||||
|
| **Time Trial (14)** | 计时赛专属:玩家最佳、个人最佳、对手数据 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 隐私:Your Telemetry 设置
|
||||||
|
|
||||||
|
当玩家设置 `Your Telemetry = Restricted` 时,以下数据对其他玩家**归零**:
|
||||||
|
- **Car Status**:燃油、ERS 相关数据
|
||||||
|
- **Car Damage**:所有损伤数据
|
||||||
|
- **Tyre Sets**:全部轮胎组数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 F1 24 相比 F1 23 的主要变化
|
||||||
|
|
||||||
|
1. 会话包中新增模拟/规则选项
|
||||||
|
2. 新增安全车事件详情(`SCAR`)
|
||||||
|
3. 大厅和参与者包中新增在线名称、Your Telemetry、F1 World Tech Level
|
||||||
|
4. 圈速包中新增与前车/领先者的分钟差距字段
|
||||||
|
5. 最终排名包改为结果界面每 5 秒重复发送
|
||||||
|
6. 新增 **Time Trial 包**(ID=14)
|
||||||
|
7. Motion Ex 包新增底盘高度(plank height)、悬挂侧倾角、底盘偏航角
|
||||||
|
8. 新增碰撞事件(`COLL`)
|
||||||
|
9. 新增周末赛程结构(`m_weekendStructure`)
|
||||||
|
10. 天气预测数组从 56 扩展到 64
|
||||||
|
11. 圈速包新增速度陷阱数据
|
||||||
|
12. Car Setup 包新增发动机制动参数 + 下次进站前翼值
|
||||||
+1
-11
@@ -238,12 +238,6 @@
|
|||||||
- **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节**
|
- **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节**
|
||||||
- **FH4**:和 FH5 基本一致
|
- **FH4**:和 FH5 基本一致
|
||||||
|
|
||||||
好问题!您确实很难在游戏中见到 P 档和 N 档,因为正常驾驶时它们几乎不出现。让我查一下社区中关于这个细节的讨论。
|
|
||||||
|
|
||||||
让我搜索更详细的信息。让我再搜一下 GitHub 上关于这个问题的实际讨论。让我直接看看社区中资深开发者对 Gear 字段的完整说明。
|
|
||||||
|
|
||||||
根据搜索结果和实际代码分析,以下是关于 **N 档(Neutral)** 和 **P 档(Park)** 在 FH4/FH5/FH6 UDP 数据包中的完整定义:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gear 字段的完整取值范围
|
## Gear 字段的完整取值范围
|
||||||
@@ -259,11 +253,7 @@
|
|||||||
| **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 |
|
| **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 |
|
||||||
| **22+** | 理论上可能更高(如 D 档标记) | 极少见 |
|
| **22+** | 理论上可能更高(如 D 档标记) | 极少见 |
|
||||||
|
|
||||||
### 关键发现:P 档 = 21
|
### Gear 值说明
|
||||||
|
|
||||||
社区中一位资深开发者(*0x20F*,Forza Telemetry 核心开发者)明确指出:
|
|
||||||
|
|
||||||
> **Gear 21 = P (Park)**
|
|
||||||
|
|
||||||
这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。
|
这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# 📡 iRacing Telemetry API(IRSDK)完整规范
|
||||||
|
|
||||||
|
## 一、概述
|
||||||
|
|
||||||
|
iRacing 的遥测系统通过 **共享内存映射文件(Memory-Mapped File)** 工作,所有数据是**只读**的。API 版本当前为 **IRSDK_VER = 2**。
|
||||||
|
|
||||||
|
### 三种数据通道
|
||||||
|
|
||||||
|
| 通道 | 形式 | 频率 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Live Telemetry** | 共享内存 `Local\IRSDKMemMapFileName` | 60Hz(或 360Hz) | 实时变量数据,三缓冲机制 |
|
||||||
|
| **Session String** | 共享内存中的 YAML 字符串 | 变更时更新 | 会话元数据(赛道、车手、天气等) |
|
||||||
|
| **Disk Telemetry (.ibt)** | 二进制文件 | 60Hz | 按 `Alt+L` 录制,结构等同于 Live 数据 |
|
||||||
|
|
||||||
|
### 启用遥测
|
||||||
|
|
||||||
|
在 iRacing 的 `app.ini` 中确认:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[External Telemetry]
|
||||||
|
enableTelemetry=1 # 启用遥测
|
||||||
|
enableIRSDKTelemetry=1 # 启用 IRSDK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心架构
|
||||||
|
|
||||||
|
### 共享内存布局
|
||||||
|
|
||||||
|
```
|
||||||
|
[irsdk_header]
|
||||||
|
↓
|
||||||
|
[irsdk_varHeader[0] ... irsdk_varHeader[numVars-1]]
|
||||||
|
↓
|
||||||
|
[Session Info YAML String]
|
||||||
|
↓
|
||||||
|
[irsdk_varBuf[0]] → [Telemetry Data Line 0]
|
||||||
|
[irsdk_varBuf[1]] → [Telemetry Data Line 1]
|
||||||
|
[irsdk_varBuf[2]] → [Telemetry Data Line 2]
|
||||||
|
[irsdk_varBuf[3]] → [Telemetry Data Line 3] (IRSDK_MAX_BUFS = 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **三缓冲**:模拟器写满一个缓冲区后立即切到下一个,给客户端 16ms+ 窗口来读取
|
||||||
|
- **同步事件**:`Local\IRSDKDataValidEvent` — 新数据就绪时触发
|
||||||
|
|
||||||
|
### 字节序与编码
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **YAML 编码**:UTF-8
|
||||||
|
- **对齐**:16 字节对齐
|
||||||
|
- **变量名最大长**:`IRSDK_MAX_STRING = 32`
|
||||||
|
- **描述最大长**:`IRSDK_MAX_DESC = 64`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据结构
|
||||||
|
|
||||||
|
### 1. irsdk_header(主头部)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_header {
|
||||||
|
int ver; // API 版本 = 2
|
||||||
|
int status; // 位字段 (irsdk_stConnected = 1)
|
||||||
|
int tickRate; // tick 频率 (60 或 360)
|
||||||
|
|
||||||
|
// 会话信息(变更时更新)
|
||||||
|
int sessionInfoUpdate; // 会话信息变更计数
|
||||||
|
int sessionInfoLen; // YAML 字符串长度(字节)
|
||||||
|
int sessionInfoOffset; // 距头部的偏移
|
||||||
|
|
||||||
|
// 遥测变量
|
||||||
|
int numVars; // 变量数量
|
||||||
|
int varHeaderOffset; // varHeader 数组偏移
|
||||||
|
int numBuf; // 缓冲区数量(4)
|
||||||
|
int bufLen; // 每行字节数
|
||||||
|
int pad1[2]; // 16 字节对齐
|
||||||
|
|
||||||
|
irsdk_varBuf varBuf[IRSDK_MAX_BUFS]; // 4 个缓冲区
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. irsdk_varHeader(变量描述头)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_varHeader {
|
||||||
|
int type; // irsdk_VarType 枚举
|
||||||
|
int offset; // 在缓冲区行中的偏移
|
||||||
|
int count; // 数组元素数(标量 = 1)
|
||||||
|
bool countAsTime;
|
||||||
|
char pad[3]; // 16 字节对齐
|
||||||
|
|
||||||
|
char name[IRSDK_MAX_STRING]; // 变量名,如 "Speed"
|
||||||
|
char desc[IRSDK_MAX_DESC]; // 描述,如 "GPS vehicle speed"
|
||||||
|
char unit[IRSDK_MAX_STRING]; // 单位,如 "m/s"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. irsdk_varBuf(数据缓冲区)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_varBuf {
|
||||||
|
int tickCount; // 检测数据更新
|
||||||
|
int bufOffset; // 距头部的偏移
|
||||||
|
int pad[2]; // 16 字节对齐
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. irsdk_VarType(变量类型)
|
||||||
|
|
||||||
|
| 枚举值 | 类型 | 字节数 |
|
||||||
|
|---|---|---|
|
||||||
|
| `irsdk_char = 0` | char | 1 |
|
||||||
|
| `irsdk_bool` | bool | 1 |
|
||||||
|
| `irsdk_int` | int32 | 4 |
|
||||||
|
| `irsdk_bitField` | bitfield (int32) | 4 |
|
||||||
|
| `irsdk_float` | float32 | 4 |
|
||||||
|
| `irsdk_double` | float64 | 8 |
|
||||||
|
|
||||||
|
### 5. irsdk_diskSubHeader(仅 .ibt 文件)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_diskSubHeader {
|
||||||
|
time_t sessionStartDate;
|
||||||
|
double sessionStartTime;
|
||||||
|
double sessionEndTime;
|
||||||
|
int sessionLapCount;
|
||||||
|
int sessionRecordCount;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Session String(YAML 结构)
|
||||||
|
|
||||||
|
YAML 字符串通过 `irsdk_getSessionInfoStr()` 获取,包含以下顶级节点:
|
||||||
|
|
||||||
|
### WeekendInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
WeekendInfo:
|
||||||
|
TrackName: "suzuka"
|
||||||
|
TrackID: 123
|
||||||
|
TrackLength: "5807 m"
|
||||||
|
TrackDisplayName: "Suzuka International Racing Course"
|
||||||
|
TrackCity: "Suzuka"
|
||||||
|
TrackCountry: "JPN"
|
||||||
|
TrackNumTurns: 18
|
||||||
|
TrackPitSpeedLimit: "80 kph"
|
||||||
|
TrackType: "road"
|
||||||
|
TrackWeatherType: "Dynamic"
|
||||||
|
TrackSkies: "Partly Cloudy"
|
||||||
|
TrackSurfaceTemp: "38 C"
|
||||||
|
TrackAirTemp: "25 C"
|
||||||
|
TrackWindVel: "3 m/s"
|
||||||
|
TrackWindDir: "180 deg"
|
||||||
|
TrackRelativeHumidity: "55 %"
|
||||||
|
TrackFogLevel: "0 %"
|
||||||
|
SeriesID: 100
|
||||||
|
SeasonID: 2024
|
||||||
|
SessionID: 456
|
||||||
|
SubSessionID: 789
|
||||||
|
LeagueID: 0
|
||||||
|
Official: 1
|
||||||
|
RaceWeek: 5
|
||||||
|
EventType: "Race"
|
||||||
|
Category: "Road"
|
||||||
|
SimMode: "full"
|
||||||
|
TeamRacing: 0
|
||||||
|
MinDrivers: 6
|
||||||
|
MaxDrivers: 26
|
||||||
|
NumCarClasses: 1
|
||||||
|
NumCarTypes: 3
|
||||||
|
WeekendOptions:
|
||||||
|
NumStarters: 20
|
||||||
|
StartingGrid: "2x2"
|
||||||
|
QualifyScoring: "single"
|
||||||
|
CourseCautions: "full"
|
||||||
|
StandingStart: 0
|
||||||
|
Restarts: "singleFile"
|
||||||
|
WeatherType: "Dynamic"
|
||||||
|
Skies: "Partly Cloudy"
|
||||||
|
WindDirection: "180 deg"
|
||||||
|
WindSpeed: "3 m/s"
|
||||||
|
WeatherTemp: "25 C"
|
||||||
|
RelativeHumidity: "55 %"
|
||||||
|
FogLevel: "0 %"
|
||||||
|
Unofficial: 0
|
||||||
|
IsFixedSetup: 0
|
||||||
|
HardcoreLevel: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
SessionInfo:
|
||||||
|
Sessions:
|
||||||
|
- SessionNum: 0
|
||||||
|
SessionLaps: "unlimited"
|
||||||
|
SessionTime: "30 min"
|
||||||
|
SessionType: "Practice"
|
||||||
|
ResultsPositions: []
|
||||||
|
ResultsFastestLap: []
|
||||||
|
- SessionNum: 1
|
||||||
|
SessionLaps: "2 laps"
|
||||||
|
SessionTime: "10 min"
|
||||||
|
SessionType: "Lone Qualify"
|
||||||
|
- SessionNum: 2
|
||||||
|
SessionLaps: "12 laps"
|
||||||
|
SessionTime: "30 min"
|
||||||
|
SessionType: "Race"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DriverInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
DriverInfo:
|
||||||
|
DriverCarIdx: 0
|
||||||
|
DriverCarIdleRPM: 3500.0
|
||||||
|
DriverCarRedLine: 9500.0
|
||||||
|
DriverCarSLFirstRPM: 8500.0
|
||||||
|
DriverCarSLShiftRPM: 9000.0
|
||||||
|
DriverCarSLLastRPM: 9500.0
|
||||||
|
DriverCarSLBlinkRPM: 9200.0
|
||||||
|
Drivers:
|
||||||
|
- CarIdx: 0
|
||||||
|
UserName: "John Doe"
|
||||||
|
AbbrevName: "J. Doe"
|
||||||
|
Initials: "JD"
|
||||||
|
CarNumber: "23"
|
||||||
|
CarPath: "mercedesamggt3"
|
||||||
|
CarClassID: 1
|
||||||
|
IRating: 2500
|
||||||
|
LicString: "A 4.99"
|
||||||
|
IsSpectator: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### CameraInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CameraInfo:
|
||||||
|
Groups:
|
||||||
|
- GroupNum: 1
|
||||||
|
GroupName: "TV1"
|
||||||
|
Cameras:
|
||||||
|
- CameraNum: 1
|
||||||
|
CameraName: "TV1_Turn1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SplitTimeInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
SplitTimeInfo:
|
||||||
|
Sectors:
|
||||||
|
- SectorNum: 0
|
||||||
|
SectorStartPct: 0.0
|
||||||
|
- SectorNum: 1
|
||||||
|
SectorStartPct: 0.35
|
||||||
|
- SectorNum: 2
|
||||||
|
SectorStartPct: 0.70
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心遥测变量列表(完整)
|
||||||
|
|
||||||
|
### 🏎️ 玩家车辆控制
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SteeringWheelAngle` | float | rad | 方向盘转角 |
|
||||||
|
| `Throttle` | float | % (0-1) | 油门(处理后的) |
|
||||||
|
| `Brake` | float | % (0-1) | 刹车(处理后的) |
|
||||||
|
| `Clutch` | float | % (0-1) | 离合 |
|
||||||
|
| `Gear` | int | — | -1=R,0=N,1~n=前进档 |
|
||||||
|
| `RPM` | float | revs/min | 引擎转速 |
|
||||||
|
| `ThrottleRaw` | float | % (0-1) | 原始油门输入 |
|
||||||
|
| `BrakeRaw` | float | % (0-1) | 原始刹车输入 |
|
||||||
|
| `HandbrakeRaw` | float | % (0-1) | 原始手刹输入 |
|
||||||
|
| `Speed` | float | m/s | GPS 车速 |
|
||||||
|
| `OnPitRoad` | bool | — | 是否在维修区通道 |
|
||||||
|
|
||||||
|
### 📐 位置与运动
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Yaw` | float | rad | 偏航角 |
|
||||||
|
| `YawNorth` | float | rad | 相对北方的偏航角 |
|
||||||
|
| `Pitch` | float | rad | 俯仰角 |
|
||||||
|
| `Roll` | float | rad | 侧倾角 |
|
||||||
|
| `YawRate` / `YawRate_ST` | float | rad/s | 偏航率(ST=子tick精度) |
|
||||||
|
| `PitchRate` / `PitchRate_ST` | float | rad/s | 俯仰率 |
|
||||||
|
| `RollRate` / `RollRate_ST` | float | rad/s | 侧倾率 |
|
||||||
|
| `VelocityX` / `VelocityX_ST` | float | m/s | X 方向速度 |
|
||||||
|
| `VelocityY` / `VelocityY_ST` | float | m/s | Y 方向速度 |
|
||||||
|
| `VelocityZ` / `VelocityZ_ST` | float | m/s | Z 方向速度 |
|
||||||
|
| `LatAccel` / `LatAccel_ST` | float | m/s² | 横向加速度 |
|
||||||
|
| `LongAccel` / `LongAccel_ST` | float | m/s² | 纵向加速度 |
|
||||||
|
| `VertAccel` / `VertAccel_ST` | float | m/s² | 垂向加速度 |
|
||||||
|
|
||||||
|
> `_ST` 后缀变量提供 **子 tick 精度**,适合运动平台等精密场景。
|
||||||
|
|
||||||
|
### ⏱️ 圈速与计时
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Lap` | int | — | 当前圈号 |
|
||||||
|
| `LapCompleted` | int | — | 已完成圈数 |
|
||||||
|
| `LapDist` | float | m | 当前圈已跑距离 |
|
||||||
|
| `LapDistPct` | float | % | 当前圈完成百分比 |
|
||||||
|
| `RaceLaps` | int | — | 比赛中已完成圈数 |
|
||||||
|
| `LapBestLap` | int | — | 最佳圈号 |
|
||||||
|
| `LapBestLapTime` | float | s | 最佳圈速 |
|
||||||
|
| `LapLastLapTime` | float | s | 上一圈用时 |
|
||||||
|
| `LapCurrentLapTime` | float | s | 当前圈用时(F3 黑盒) |
|
||||||
|
| `LapBestNLapLap` | int | — | 最佳 N 圈均值的最后一圈 |
|
||||||
|
| `LapBestNLapTime` | float | s | 最佳 N 圈平均时间 |
|
||||||
|
| `LapLastNLapTime` | float | s | 最近 N 圈平均时间 |
|
||||||
|
| `LapLasNLapSeq` | int | — | 连续干净圈数 |
|
||||||
|
|
||||||
|
### 🎯 Delta 时间
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `LapDeltaToBestLap` | float | s | 与个人最佳圈的差距 |
|
||||||
|
| `LapDeltaToBestLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToBestLap_OK` | bool | — | delta 数据是否有效 |
|
||||||
|
| `LapDeltaToOptimalLap` | float | s | 与理论最佳圈的差距 |
|
||||||
|
| `LapDeltaToOptimalLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToOptimalLap_OK` | bool | — | 是否有效 |
|
||||||
|
| `LapDeltaToSessionBestLap` | float | s | 与全场最佳圈差距 |
|
||||||
|
| `LapDeltaToSessionBestLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToSessionBestLap_OK` | bool | — | 是否有效 |
|
||||||
|
| `LapDeltaToSessionOptimalLap` | float | s | 与全场理论最佳差距 |
|
||||||
|
| `LapDeltaToSessionLastlLap` | float | s | 与全场上一圈差距 |
|
||||||
|
|
||||||
|
### ⛽ 引擎 & 燃油
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `FuelLevel` | float | L | 剩余油量(升) |
|
||||||
|
| `FuelLevelPct` | float | % | 剩余油量百分比 |
|
||||||
|
| `FuelPress` | float | bar | 燃油压力 |
|
||||||
|
| `FuelUsePerHour` | float | kg/h | 瞬时油耗率 |
|
||||||
|
| `ManifoldPress` | float | bar | 进气歧管压力 |
|
||||||
|
| `OilLevel` | float | L | 机油液位 |
|
||||||
|
| `OilPress` | float | bar | 机油压力 |
|
||||||
|
| `OilTemp` | float | °C | 机油温度 |
|
||||||
|
| `WaterLevel` | float | L | 冷却液液位 |
|
||||||
|
| `WaterTemp` | float | °C | 冷却液温度 |
|
||||||
|
| `Voltage` | float | V | 电压 |
|
||||||
|
| `EngineWarnings` | bitfield | — | 引擎警告灯(见下文) |
|
||||||
|
| `BrakeABSactive` | bool | — | ABS 是否激活 |
|
||||||
|
|
||||||
|
### 🛞 轮胎数据(每轮 LF/RF/LR/RR)
|
||||||
|
|
||||||
|
设 `X` ∈ {LF, RF, LR, RR}:
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `XcoldPressure` | float | kPa | 冷胎压 |
|
||||||
|
| `Xpressure` | float | kPa | 当前胎压 |
|
||||||
|
| `XtempCL/M/R` | float | °C | 胎面左/中/右温度 |
|
||||||
|
| `XwearL/M/R` | float | % | 胎面左/中/右磨损 |
|
||||||
|
| `XbrakeLinePress` | float | bar | 刹车管压力 |
|
||||||
|
| `XshockDefl` | float | mm | 悬挂压缩量 |
|
||||||
|
| `XshockVel` | float | mm/s | 悬挂速度 |
|
||||||
|
| `XrideHeight` | float | mm | 行驶高度 |
|
||||||
|
| `Xspeed` | float | rad/s | 轮速 |
|
||||||
|
|
||||||
|
### 🎛️ 力反馈
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SteeringWheelTorque` | float | N·m | 转向轴输出扭矩 |
|
||||||
|
| `SteeringWheelTorque_ST` | float | N·m | 转向轴扭矩(子tick) |
|
||||||
|
| `SteeringWheelPctTorque` | float | % | FFB 扭矩百分比(无符号) |
|
||||||
|
| `SteeringWheelPctTorqueSign` | float | % | FFB 扭矩百分比(有符号) |
|
||||||
|
| `SteeringWheelPctTorqueSignStops` | float | % | FFB 扭矩百分比(含止挡) |
|
||||||
|
| `SteeringWheelPctDamper` | float | % | FFB 阻尼百分比 |
|
||||||
|
| `SteeringWheelAngleMax` | float | rad | 方向盘最大转角 |
|
||||||
|
| `SteeringWheelPeakForceNm` | float | N·m | FFB 峰值力映射 |
|
||||||
|
| `SteeringWheelMaxForceNm` | float | N·m | 最大力 |
|
||||||
|
| `SteeringWheelUseLinear` | bool | — | 是否线性模式 |
|
||||||
|
|
||||||
|
### 🏁 会话状态
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SessionTime` | double | s | 当前会话已过时间 |
|
||||||
|
| `SessionTimeRemain` | double | s | 会话剩余时间 |
|
||||||
|
| `SessionTimeTotal` | double | s | 会话总时间 |
|
||||||
|
| `SessionLapsRemain` | int | — | 剩余圈数 |
|
||||||
|
| `SessionLapsRemainEx` | int | — | 扩展剩余圈数 |
|
||||||
|
| `SessionLapsTotal` | int | — | 总圈数 |
|
||||||
|
| `SessionNum` | int | — | 会话序号 |
|
||||||
|
| `SessionState` | int | — | 会话状态(见枚举) |
|
||||||
|
| `SessionUniqueID` | int | — | 会话唯一 ID |
|
||||||
|
| `SessionFlags` | bitfield | — | 旗语(见下文) |
|
||||||
|
| `SessionTick` | int | — | Tick 计数 |
|
||||||
|
| `SessionTimeOfDay` | float | s | 赛道时间 |
|
||||||
|
|
||||||
|
### 🚩 旗语 & 状态
|
||||||
|
|
||||||
|
**SessionFlags** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x00000001` | 🏁 方格旗 |
|
||||||
|
| `0x00000002` | ⬜ 白旗 |
|
||||||
|
| `0x00000004` | 🟩 绿旗 |
|
||||||
|
| `0x00000008` | 🟨 黄旗 |
|
||||||
|
| `0x00000010` | 🟥 红旗 |
|
||||||
|
| `0x00000020` | 🟦 蓝旗 |
|
||||||
|
| `0x00000040` | 💥 碎片 |
|
||||||
|
| `0x00000100` | 🟨 黄旗挥动 |
|
||||||
|
| `0x00000200` | 绿旗前最后一圈 |
|
||||||
|
| `0x00004000` | ⚠️ Full Course Caution |
|
||||||
|
| `0x00010000` | ⬛ 黑旗 |
|
||||||
|
| `0x00020000` | ❌ DSQ |
|
||||||
|
| `0x10000000` | 🔴 发车灯隐藏 |
|
||||||
|
| `0x20000000` | 🔴 发车灯就绪 |
|
||||||
|
| `0x40000000` | 🔴 发车灯 Set |
|
||||||
|
| `0x80000000` | 🟢 发车灯 Go |
|
||||||
|
|
||||||
|
**EngineWarnings** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x0001` | 水温警告 |
|
||||||
|
| `0x0002` | 燃油压力警告 |
|
||||||
|
| `0x0004` | 机油压力警告 |
|
||||||
|
| `0x0008` | 引擎熄火 |
|
||||||
|
| `0x0010` | Pit Speed Limiter |
|
||||||
|
| `0x0020` | 转速限制器激活 |
|
||||||
|
| `0x0040` | 油温警告 |
|
||||||
|
|
||||||
|
### 🚗 多车数据(CarIdx 数组,最多 64 辆车)
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CarIdxLap` | int[] | 每辆车的圈数 |
|
||||||
|
| `CarIdxLapCompleted` | int[] | 每辆车已完成圈数 |
|
||||||
|
| `CarIdxLapDistPct` | float[] | 每辆车的位置百分比 |
|
||||||
|
| `CarIdxPosition` | int[] | 每辆车的排名 |
|
||||||
|
| `CarIdxClassPosition` | int[] | 组别排名 |
|
||||||
|
| `CarIdxClass` | int[] | 组别 ID |
|
||||||
|
| `CarIdxF2Time` | float[] | 与领先者的差距(或最快圈速) |
|
||||||
|
| `CarIdxEstTime` | float[] | 预计到达当前位置的时间 |
|
||||||
|
| `CarIdxLastLapTime` | float[] | 上一圈时间 |
|
||||||
|
| `CarIdxBestLapTime` | float[] | 最佳圈时间 |
|
||||||
|
| `CarIdxBestLapNum` | int[] | 最佳圈号 |
|
||||||
|
| `CarIdxSteer` | float[] | 方向盘转角 |
|
||||||
|
| `CarIdxRPM` | float[] | 引擎转速 |
|
||||||
|
| `CarIdxGear` | int[] | 档位 |
|
||||||
|
| `CarIdxOnPitRoad` | bool[] | 是否在维修区 |
|
||||||
|
| `CarIdxTrackSurface` | int[] | 赛道位置类型 |
|
||||||
|
| `CarIdxTrackSurfaceMaterial` | int[] | 路面材质 |
|
||||||
|
| `CarIdxTireCompound` | int[] | 轮胎配方 |
|
||||||
|
| `CarIdxP2P_Status` | bool[] | Push-to-Pass 状态 |
|
||||||
|
| `CarIdxP2P_Count` | int[] | Push-to-Pass 剩余次数 |
|
||||||
|
|
||||||
|
### 🛑 进站 & 维修
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PitsOpen` | bool | — | 维修区是否开放 |
|
||||||
|
| `PitstopActive` | bool | — | 正在进站 |
|
||||||
|
| `PitRepairLeft` | float | s | 强制维修剩余时间 |
|
||||||
|
| `PitOptRepairLeft` | float | s | 可选维修剩余时间 |
|
||||||
|
| `PitSvFlags` | bitfield | — | 进站服务勾选 |
|
||||||
|
| `PitSvLFP/RFP/LRP/RRP` | float | kPa | 指定胎压 |
|
||||||
|
| `PitSvFuel` | float | L | 指定加油量 |
|
||||||
|
| `PitSvTireCompound` | int | — | 指定轮胎配方 |
|
||||||
|
| `FastRepairUsed` | int | — | 已用快速维修次数 |
|
||||||
|
| `FastRepairAvailable` | int | — | 可用快速维修次数 |
|
||||||
|
| `PlayerCarInPitStall` | bool | — | 玩家在维修位 |
|
||||||
|
| `PlayerCarPitSvStatus` | int | — | 进站服务状态 |
|
||||||
|
|
||||||
|
**PitSvFlags** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x0001` | 左前换胎 |
|
||||||
|
| `0x0002` | 右前换胎 |
|
||||||
|
| `0x0004` | 左后换胎 |
|
||||||
|
| `0x0008` | 右后换胎 |
|
||||||
|
| `0x0010` | 加油 |
|
||||||
|
| `0x0020` | 撕膜 |
|
||||||
|
| `0x0040` | 快速维修 |
|
||||||
|
|
||||||
|
### 🌦️ 天气 & 环境
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `TrackTemp` | float | °C | 赛道温度(起终点) |
|
||||||
|
| `TrackTempCrew` | float | °C | 赛道温度(Pit 区) |
|
||||||
|
| `TrackWetness` | float | — | 赛道湿度级别 |
|
||||||
|
| `AirTemp` | float | °C | 空气温度 |
|
||||||
|
| `AirDensity` | float | kg/m³ | 空气密度 |
|
||||||
|
| `AirPressure` | float | Hg | 气压 |
|
||||||
|
| `WindVel` | float | m/s | 风速 |
|
||||||
|
| `WindDir` | float | rad | 风向 |
|
||||||
|
| `RelativeHumidity` | float | % | 相对湿度 |
|
||||||
|
| `FogLevel` | float | % | 雾级别 |
|
||||||
|
| `WeatherType` | int | — | 0=恒定,1=动态 |
|
||||||
|
| `Skies` | int | — | 0=晴,1=少云,2=多云,3=阴 |
|
||||||
|
|
||||||
|
### 🎥 回放 & 系统
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `IsReplayPlaying` | bool | 是否回放中 |
|
||||||
|
| `ReplayFrameNum` | int | 回放当前帧号 |
|
||||||
|
| `ReplayFrameNumEnd` | int | 回放末尾帧号 |
|
||||||
|
| `ReplayPlaySpeed` | int | 回放速度 |
|
||||||
|
| `ReplayPlaySlowMotion` | bool | 是否慢动作 |
|
||||||
|
| `ReplaySessionTime` | double | 回放中的会话时间 |
|
||||||
|
| `ReplaySessionNum` | int | 回放会话序号 |
|
||||||
|
| `IsOnTrack` | bool | 玩家是否在赛道上 |
|
||||||
|
| `IsInGarage` | bool | 是否在车库 |
|
||||||
|
| `IsDiskLoggingEnabled` | bool | 磁盘遥测是否开启 |
|
||||||
|
| `IsDiskLoggingActive` | bool | 是否正在录制 .ibt |
|
||||||
|
|
||||||
|
### 👥 车手变更(Team Racing)
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DCLapStatus` | int | Driver Change 圈数状态 |
|
||||||
|
| `DCDriversSoFar` | int | 已驾驶过的车手数 |
|
||||||
|
|
||||||
|
### 📷 摄像机
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CamCarIdx` | int | 当前焦点车辆索引 |
|
||||||
|
| `CamCameraNumber` | int | 当前摄像机编号 |
|
||||||
|
| `CamGroupNumber` | int | 当前摄像机组编号 |
|
||||||
|
| `CamCameraState` | bitfield | 摄像机状态位字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、重要枚举汇总
|
||||||
|
|
||||||
|
### SessionState
|
||||||
|
|
||||||
|
| 值 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | StateInvalid |
|
||||||
|
| 1 | StateGetInCar(上车中) |
|
||||||
|
| 2 | StateWarmup(暖胎圈) |
|
||||||
|
| 3 | StateParadeLaps(编队圈) |
|
||||||
|
| 4 | **StateRacing**(正赛中) |
|
||||||
|
| 5 | StateCheckered(方格旗) |
|
||||||
|
| 6 | StateCoolDown(冷却圈) |
|
||||||
|
|
||||||
|
### TrkLoc(赛道位置)
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| -1 | NotInWorld |
|
||||||
|
| 0 | OffTrack |
|
||||||
|
| 1 | InPitStall(维修区) |
|
||||||
|
| 2 | AproachingPits |
|
||||||
|
| 3 | OnTrack |
|
||||||
|
|
||||||
|
### TrkSurf(路面材质)
|
||||||
|
|
||||||
|
| 值 | 材质 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Undefined |
|
||||||
|
| 1-4 | Asphalt |
|
||||||
|
| 5-6 | Concrete |
|
||||||
|
| 7-8 | Racing Dirt |
|
||||||
|
| 9-10 | Paint |
|
||||||
|
| 11-14 | Rumble Strip(路肩) |
|
||||||
|
| 15-18 | Grass |
|
||||||
|
| 19-22 | Dirt |
|
||||||
|
| 23 | Sand |
|
||||||
|
| 24-25 | Gravel |
|
||||||
|
| 26 | Grasscrete |
|
||||||
|
| 27 | Astroturf |
|
||||||
|
|
||||||
|
### TrackWetness(赛道湿度)
|
||||||
|
|
||||||
|
| 值 | 级别 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Unknown |
|
||||||
|
| 1 | Dry |
|
||||||
|
| 2 | Mostly Dry |
|
||||||
|
| 3 | Very Lightly Wet |
|
||||||
|
| 4 | Lightly Wet |
|
||||||
|
| 5 | Moderately Wet |
|
||||||
|
| 6 | Very Wet |
|
||||||
|
| 7 | Extremely Wet |
|
||||||
|
|
||||||
|
### PaceMode
|
||||||
|
|
||||||
|
| 值 | 模式 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Single File Start |
|
||||||
|
| 1 | Double File Start |
|
||||||
|
| 2 | Single File Restart |
|
||||||
|
| 3 | Double File Restart |
|
||||||
|
| 4 | Not Pacing |
|
||||||
|
|
||||||
|
### CarLeftRight
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off |
|
||||||
|
| 1 | Clear(两侧无车) |
|
||||||
|
| 2 | Car Left |
|
||||||
|
| 3 | Car Right |
|
||||||
|
| 4 | Cars on Both Sides |
|
||||||
|
| 5 | 2 Cars Left |
|
||||||
|
| 6 | 2 Cars Right |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、SDK 客户端 API(C 语言接口)
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 初始化 / 关闭
|
||||||
|
bool irsdk_startup();
|
||||||
|
void irsdk_shutdown();
|
||||||
|
|
||||||
|
// 连接检测
|
||||||
|
bool irsdk_isConnected();
|
||||||
|
|
||||||
|
// 获取头部
|
||||||
|
const irsdk_header* irsdk_getHeader();
|
||||||
|
|
||||||
|
// 等待新数据并复制到本地缓冲区(推荐方式)
|
||||||
|
bool irsdk_waitForDataReady(int timeOut, char *data);
|
||||||
|
|
||||||
|
// 获取会话 YAML 字符串
|
||||||
|
const char* irsdk_getSessionInfoStr();
|
||||||
|
int irsdk_getSessionInfoStrUpdate(); // 检测会话信息更新
|
||||||
|
|
||||||
|
// 获取变量头
|
||||||
|
const irsdk_varHeader* irsdk_getVarHeaderPtr();
|
||||||
|
const irsdk_varHeader* irsdk_getVarHeaderEntry(int index);
|
||||||
|
|
||||||
|
// 按名称查找变量
|
||||||
|
int irsdk_varNameToIndex(const char *name);
|
||||||
|
int irsdk_varNameToOffset(const char *name);
|
||||||
|
|
||||||
|
// 获取指定缓冲区的数据
|
||||||
|
const char* irsdk_getData(int index);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Broadcast Messages(远程控制)
|
||||||
|
|
||||||
|
通过 Windows 消息 `IRSDK_BROADCASTMSG` 发送控制命令:
|
||||||
|
|
||||||
|
### 摄像机控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 切换摄像机:按位置
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchPos, carPosition, group, camera);
|
||||||
|
|
||||||
|
// 切换摄像机:按车号
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchNum, driverNum, group, camera);
|
||||||
|
|
||||||
|
// 设置摄像机状态
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSetState, irsdk_CameraState, 0, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回放控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 设置播放速度
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySetPlaySpeed, speed, slowMotion, 0);
|
||||||
|
|
||||||
|
// 跳转位置
|
||||||
|
irsdk_broadcastMsg(irskd_BroadcastReplaySetPlayPosition,
|
||||||
|
irsdk_RpyPos_Begin, frameHigh, frameLow);
|
||||||
|
|
||||||
|
// 搜索事件
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySearch,
|
||||||
|
irsdk_RpySrch_NextLap, 0, 0);
|
||||||
|
|
||||||
|
// 搜索会话时间
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySearchSessionTime,
|
||||||
|
sessionNum, sessionTimeMS_high, sessionTimeMS_low);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 进站控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 清空所有勾选
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Clear, 0);
|
||||||
|
|
||||||
|
// 加油(0 = 使用默认值)
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Fuel, addLiters);
|
||||||
|
|
||||||
|
// 换左前胎(指定胎压 kPa,0 = 默认)
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_LF, pressureKpa);
|
||||||
|
|
||||||
|
// 换胎配方
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_TC, compound);
|
||||||
|
|
||||||
|
// 快速维修
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_FR, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 遥测录制控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Start, 0, 0);
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Stop, 0, 0);
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Restart, 0, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 聊天宏
|
||||||
|
|
||||||
|
```c
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastChatComand, irsdk_ChatCommand_Macro, macroNum, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、与 F1 24 / ACC 的对比
|
||||||
|
|
||||||
|
| 特性 | iRacing | F1 24 | ACC |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **协议** | 共享内存 | UDP | UDP + 共享内存 |
|
||||||
|
| **更新频率** | 60Hz(可 360Hz) | 最高 60Hz | 物理步进 |
|
||||||
|
| **多车数据** | ✅ CarIdx 数组(≤64 辆车) | ✅ 22 辆车 | ✅ Broadcasting |
|
||||||
|
| **回放数据** | ✅ .ibt 文件 | ❌ | ❌ |
|
||||||
|
| **远程控制** | ✅ Broadcast Msg | ❌ | ✅ Camera/Focus |
|
||||||
|
| **写入支持** | ✅ 进站/摄像机/遥测 | ❌ | ✅ Camera/HUD |
|
||||||
|
| **变量动态性** | 每车/每会话不同 | 固定结构 | 固定结构 |
|
||||||
|
| **子 Tick 精度** | ✅ `_ST` 后缀变量 | ❌ | ❌ |
|
||||||
|
| **运动平台** | ✅ 完整扭矩/加速度 | ✅ Motion Ex | ✅ Shared Memory |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、关键注意事项
|
||||||
|
|
||||||
|
1. **变量动态性**:iRacing 的变量列表不是固定的—不同车辆/会话中可能不同,必须先读取 `varHeader` 来确定可用的变量和偏移量
|
||||||
|
2. **只读**:所有遥测数据只读,不能通过 SDK 修改车辆参数(只能通过 Broadcast Message 控制进站选项)
|
||||||
|
3. **三缓冲**:务必在 `irsdk_waitForDataReady()` 返回后立即拷贝数据,不要直接引用共享内存
|
||||||
|
4. **Session Info 更新**:通过 `sessionInfoUpdate` 计数器检测 YAML 字符串是否变化
|
||||||
|
5. **.ibt 兼容**:磁盘遥测文件结构与 Live 相同,可用相同代码解析(多了 `irsdk_diskSubHeader`)
|
||||||
|
6. **启动时序**:先调用 `irsdk_startup()` → 等待 `irsdk_isConnected()` → 等待 `SessionState >= StateRacing`
|
||||||
|
7. **360Hz 模式**:部分车辆支持 360Hz 遥测,检查 `tickRate` 字段
|
||||||
+5
-1
@@ -17,6 +17,8 @@ games/user/my_game/
|
|||||||
"id": "my_game",
|
"id": "my_game",
|
||||||
"name": "我的游戏",
|
"name": "我的游戏",
|
||||||
"parser_type": "forza",
|
"parser_type": "forza",
|
||||||
|
"protocol": "udp",
|
||||||
|
"mode": "server",
|
||||||
"description": "自定义游戏遥测",
|
"description": "自定义游戏遥测",
|
||||||
"author": "你的名字",
|
"author": "你的名字",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -29,10 +31,12 @@ games/user/my_game/
|
|||||||
| `id` | string | 是 | 唯一标识,= 文件夹名 |
|
| `id` | string | 是 | 唯一标识,= 文件夹名 |
|
||||||
| `name` | string | 是 | 显示名称 |
|
| `name` | string | 是 | 显示名称 |
|
||||||
| `parser_type` | string | 是 | 解析器类型标签 |
|
| `parser_type` | string | 是 | 解析器类型标签 |
|
||||||
|
| `protocol` | string | 否 | 传输协议: `udp`(默认) / `tcp` |
|
||||||
|
| `mode` | string | 否 | TCP模式: `server`(框架监听) / `client`(插件自连) |
|
||||||
| `description` | string | 否 | 描述文字 |
|
| `description` | string | 否 | 描述文字 |
|
||||||
| `author` | string | 否 | 作者 |
|
| `author` | string | 否 | 作者 |
|
||||||
| `version` | string | 否 | 版本号 |
|
| `version` | string | 否 | 版本号 |
|
||||||
| `default_port` | int | 否 | 默认 UDP 端口 (默认 20777) |
|
| `default_port` | int | 否 | 默认端口 (UDP默认20777, TCP默认20778) |
|
||||||
|
|
||||||
## parser.py 规范
|
## parser.py 规范
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TurboSu Service
|
||||||
|
After=network-online.target
|
||||||
|
Requires=frpc.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=Aska
|
||||||
|
WorkingDirectory=/home/Aska/Project/TurboSu
|
||||||
|
ExecStart=/home/Aska/Project/TurboSu/venv/bin/python /home/Aska/Project/TurboSu/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=append:/home/Aska/Project/TurboSu/logs/turbosu.log
|
||||||
|
StandardError=append:/home/Aska/Project/TurboSu/logs/turbosu.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -8,6 +8,12 @@ def get_parser():
|
|||||||
|
|
||||||
|
|
||||||
class F124Parser:
|
class F124Parser:
|
||||||
|
"""F1 24 UDP telemetry parser based on official EA SPORTS specification."""
|
||||||
|
|
||||||
|
# Packet header (29 bytes)
|
||||||
|
HEADER_SIZE = 29
|
||||||
|
CAR_TELEMETRY_SIZE = 43 # sizeof(CarTelemetryData) with padding
|
||||||
|
|
||||||
def game_id(self) -> str:
|
def game_id(self) -> str:
|
||||||
return "f1_24"
|
return "f1_24"
|
||||||
|
|
||||||
@@ -15,22 +21,64 @@ class F124Parser:
|
|||||||
td = TelemetryData(game_id="f1_24", timestamp=time.time())
|
td = TelemetryData(game_id="f1_24", timestamp=time.time())
|
||||||
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
|
||||||
|
|
||||||
if len(data) < 1289:
|
if len(data) < 50:
|
||||||
return td
|
return td
|
||||||
|
|
||||||
try:
|
try:
|
||||||
td.speed_kmh = struct.unpack_from("<f", data, 37)[0]
|
# Parse header
|
||||||
|
packet_id = struct.unpack_from("<B", data, 6)[0]
|
||||||
|
player_idx = struct.unpack_from("<B", data, 27)[0]
|
||||||
|
td.raw["packet_id"] = packet_id
|
||||||
|
td.raw["player_index"] = player_idx
|
||||||
|
|
||||||
|
if packet_id == 6: # Car Telemetry Packet (ID=6)
|
||||||
|
offset = self.HEADER_SIZE + player_idx * self.CAR_TELEMETRY_SIZE
|
||||||
|
|
||||||
|
td.speed_kmh = struct.unpack_from("<H", data, offset)[0]
|
||||||
td.speed_mph = td.speed_kmh * 0.621371
|
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.throttle = struct.unpack_from("<f", data, offset + 4)[0]
|
||||||
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F
|
td.steering = struct.unpack_from("<f", data, offset + 8)[0]
|
||||||
td.throttle = struct.unpack_from("<f", data, 47)[0]
|
td.brake = struct.unpack_from("<f", data, offset + 12)[0]
|
||||||
td.brake = struct.unpack_from("<f", data, 55)[0]
|
td.clutch = struct.unpack_from("<B", data, offset + 16)[0] / 100.0
|
||||||
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0
|
td.gear = struct.unpack_from("<b", data, offset + 17)[0]
|
||||||
td.lap_number = struct.unpack_from("<B", data, 262)[0]
|
td.rpm = struct.unpack_from("<H", data, offset + 18)[0]
|
||||||
td.lap_time = struct.unpack_from("<f", data, 63)[0]
|
td.raw["drs"] = struct.unpack_from("<B", data, offset + 20)[0]
|
||||||
td.best_lap = struct.unpack_from("<f", data, 268)[0]
|
td.raw["rev_lights_percent"] = struct.unpack_from("<B", data, offset + 21)[0]
|
||||||
td.fuel = struct.unpack_from("<f", data, 51)[0]
|
td.raw["rev_lights_bit"] = struct.unpack_from("<H", data, offset + 22)[0]
|
||||||
|
|
||||||
|
for i in range(4):
|
||||||
|
td.raw[f"tyre_surface_temp_{i}"] = struct.unpack_from("<B", data, offset + 30 + i)[0]
|
||||||
|
td.raw[f"tyre_inner_temp_{i}"] = struct.unpack_from("<B", data, offset + 34 + i)[0]
|
||||||
|
|
||||||
|
td.raw["engine_temp"] = struct.unpack_from("<H", data, offset + 38)[0]
|
||||||
|
|
||||||
|
elif packet_id == 0: # Motion Packet (ID=0)
|
||||||
|
offset = self.HEADER_SIZE + player_idx * 64 # sizeof(CarMotionData)
|
||||||
|
|
||||||
|
td.acceleration_x = struct.unpack_from("<f", data, offset + 28)[0]
|
||||||
|
td.acceleration_y = struct.unpack_from("<f", data, offset + 32)[0]
|
||||||
|
td.acceleration_z = struct.unpack_from("<f", data, offset + 36)[0]
|
||||||
|
|
||||||
|
elif packet_id == 2: # Lap Data Packet (ID=2)
|
||||||
|
offset = self.HEADER_SIZE + player_idx * 24 # sizeof(LapData)
|
||||||
|
|
||||||
|
td.last_lap = struct.unpack_from("<f", data, offset)[0]
|
||||||
|
td.lap_time = struct.unpack_from("<f", data, offset + 4)[0]
|
||||||
|
td.best_lap = struct.unpack_from("<f", data, offset + 8)[0]
|
||||||
|
|
||||||
|
elif packet_id == 7: # Car Status Packet (ID=7)
|
||||||
|
offset = self.HEADER_SIZE + player_idx * 41 # sizeof(CarStatusData)
|
||||||
|
|
||||||
|
td.raw["traction_control"] = struct.unpack_from("<B", data, offset)[0]
|
||||||
|
td.raw["anti_lock_brakes"] = struct.unpack_from("<B", data, offset + 1)[0]
|
||||||
|
td.fuel = struct.unpack_from("<f", data, offset + 2)[0] / 110.0 # normalized to 0-1
|
||||||
|
if td.fuel > 1.0:
|
||||||
|
td.fuel = min(1.0, td.fuel / 100.0)
|
||||||
|
td.max_rpm = struct.unpack_from("<H", data, offset + 26)[0]
|
||||||
|
td.raw["ers_deploy_mode"] = struct.unpack_from("<B", data, offset + 34)[0]
|
||||||
|
td.raw["ers_store_energy"] = struct.unpack_from("<f", data, offset + 36)[0]
|
||||||
|
|
||||||
except struct.error:
|
except struct.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ class ForzaHorizonParser:
|
|||||||
td.handbrake = struct.unpack_from("<B", data, 318)[0] / 255.0
|
td.handbrake = struct.unpack_from("<B", data, 318)[0] / 255.0
|
||||||
td.gear = struct.unpack_from("<B", data, 319)[0]
|
td.gear = struct.unpack_from("<B", data, 319)[0]
|
||||||
td.steering = max(-1.0, min(1.0, struct.unpack_from("<b", data, 320)[0] / 127.0))
|
td.steering = max(-1.0, min(1.0, struct.unpack_from("<b", data, 320)[0] / 127.0))
|
||||||
|
|
||||||
|
td.raw["tire_temp_fl"] = struct.unpack_from("<f", data, 268)[0]
|
||||||
|
td.raw["tire_temp_fr"] = struct.unpack_from("<f", data, 272)[0]
|
||||||
|
td.raw["tire_temp_rl"] = struct.unpack_from("<f", data, 276)[0]
|
||||||
|
td.raw["tire_temp_rr"] = struct.unpack_from("<f", data, 280)[0]
|
||||||
except struct.error:
|
except struct.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,13 @@ def get_parser():
|
|||||||
|
|
||||||
|
|
||||||
class IRacingParser:
|
class IRacingParser:
|
||||||
|
"""
|
||||||
|
iRacing uses shared memory (IRSDK Memory-Mapped File), NOT UDP.
|
||||||
|
This parser is a stub - it does NOT work with UDP input.
|
||||||
|
Requires IRSDK mmap reader (not yet implemented).
|
||||||
|
"""
|
||||||
def game_id(self) -> str:
|
def game_id(self) -> str:
|
||||||
return "iracing"
|
return "iracing"
|
||||||
|
|
||||||
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
def parse(self, data: bytes, addr: tuple) -> TelemetryData:
|
||||||
td = TelemetryData(game_id="iracing", timestamp=time.time())
|
return TelemetryData(game_id="iracing", timestamp=time.time(), raw={"note": "iRacing uses shared memory, not UDP"})
|
||||||
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
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class DashboardTheme:
|
|||||||
updated_at: str = ""
|
updated_at: str = ""
|
||||||
is_builtin: bool = False
|
is_builtin: bool = False
|
||||||
dir_path: str = ""
|
dir_path: str = ""
|
||||||
|
preview_ext: str = ""
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +52,7 @@ class DashboardTheme:
|
|||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"updated_at": self.updated_at,
|
"updated_at": self.updated_at,
|
||||||
"is_builtin": self.is_builtin,
|
"is_builtin": self.is_builtin,
|
||||||
|
"preview_ext": self.preview_ext,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -59,6 +61,12 @@ class DashboardTheme:
|
|||||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
config = data.get("config", {})
|
config = data.get("config", {})
|
||||||
|
dir_path = manifest_path.parent
|
||||||
|
preview_ext = ""
|
||||||
|
for ext in ["jpg", "jpeg", "png", "webp", "gif"]:
|
||||||
|
if (dir_path / f"preview.{ext}").exists():
|
||||||
|
preview_ext = ext
|
||||||
|
break
|
||||||
return cls(
|
return cls(
|
||||||
id=data.get("id", manifest_path.parent.name),
|
id=data.get("id", manifest_path.parent.name),
|
||||||
name=data.get("name", manifest_path.parent.name),
|
name=data.get("name", manifest_path.parent.name),
|
||||||
@@ -74,6 +82,7 @@ class DashboardTheme:
|
|||||||
updated_at=data.get("updated_at", ""),
|
updated_at=data.get("updated_at", ""),
|
||||||
is_builtin=is_builtin,
|
is_builtin=is_builtin,
|
||||||
dir_path=str(manifest_path.parent),
|
dir_path=str(manifest_path.parent),
|
||||||
|
preview_ext=preview_ext,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e)
|
logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e)
|
||||||
|
|||||||
+50
-20
@@ -84,34 +84,51 @@ class SceneManager:
|
|||||||
|
|
||||||
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
|
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
scenes = []
|
scenes = []
|
||||||
for f in SCENES_DIR.glob("*.json"):
|
for item in SCENES_DIR.iterdir():
|
||||||
|
if not item.is_dir():
|
||||||
|
continue
|
||||||
|
f = item / "manifest.json"
|
||||||
|
if not f.exists():
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
with open(f, "r", encoding="utf-8") as fp:
|
with open(f, "r", encoding="utf-8") as fp:
|
||||||
data = json.load(fp)
|
data = json.load(fp)
|
||||||
scene = Scene.from_dict(data)
|
scene = Scene.from_dict(data)
|
||||||
if game_id is None or scene.game_id == game_id:
|
if game_id is None or scene.game_id == game_id:
|
||||||
scenes.append(scene.to_dict())
|
d = scene.to_dict()
|
||||||
|
for ext in ["jpg","jpeg","png","webp","gif"]:
|
||||||
|
if (item / f"preview.{ext}").exists():
|
||||||
|
d["preview_ext"] = ext
|
||||||
|
break
|
||||||
|
scenes.append(d)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to load scene %s: %s", f.name, e)
|
logger.error("Failed to load scene %s: %s", item.name, e)
|
||||||
return scenes
|
return scenes
|
||||||
|
|
||||||
def get(self, scene_id: str) -> dict[str, Any] | None:
|
def get(self, scene_id: str) -> dict[str, Any] | None:
|
||||||
filepath = SCENES_DIR / f"{scene_id}.json"
|
item = SCENES_DIR / scene_id
|
||||||
if not filepath.exists():
|
f = item / "manifest.json"
|
||||||
|
if not f.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
with open(f, "r", encoding="utf-8") as fp:
|
||||||
data = json.load(f)
|
data = json.load(fp)
|
||||||
return Scene.from_dict(data).to_dict()
|
d = Scene.from_dict(data).to_dict()
|
||||||
|
for ext in ["jpg","jpeg","png","webp","gif"]:
|
||||||
|
if (item / f"preview.{ext}").exists():
|
||||||
|
d["preview_ext"] = ext
|
||||||
|
break
|
||||||
|
return d
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to read scene %s: %s", scene_id, e)
|
logger.error("Failed to read scene %s: %s", scene_id, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save(self, scene: Scene) -> bool:
|
def save(self, scene: Scene) -> bool:
|
||||||
scene.updated_at = datetime.now().isoformat()
|
scene.updated_at = datetime.now().isoformat()
|
||||||
filepath = SCENES_DIR / f"{scene.id}.json"
|
item = SCENES_DIR / scene.id
|
||||||
|
item.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(item / "manifest.json", "w", encoding="utf-8") as f:
|
||||||
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
|
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
|
||||||
logger.info("Scene saved: %s", scene.id)
|
logger.info("Scene saved: %s", scene.id)
|
||||||
return True
|
return True
|
||||||
@@ -120,32 +137,45 @@ class SceneManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def delete(self, scene_id: str) -> bool:
|
def delete(self, scene_id: str) -> bool:
|
||||||
filepath = SCENES_DIR / f"{scene_id}.json"
|
import shutil
|
||||||
if filepath.exists():
|
item = SCENES_DIR / scene_id
|
||||||
filepath.unlink()
|
if item.exists():
|
||||||
|
shutil.rmtree(item)
|
||||||
logger.info("Scene deleted: %s", scene_id)
|
logger.info("Scene deleted: %s", scene_id)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def export_scene_zip(self, scene_id: str) -> bytes | None:
|
def export_scene_zip(self, scene_id: str) -> bytes | None:
|
||||||
filepath = SCENES_DIR / f"{scene_id}.json"
|
item = SCENES_DIR / scene_id
|
||||||
if not filepath.exists():
|
if not item.exists():
|
||||||
return None
|
return None
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
zf.write(filepath, "scene.json")
|
for f in sorted(item.rglob("*")):
|
||||||
|
if f.is_file():
|
||||||
|
zf.write(f, f.relative_to(item))
|
||||||
logger.info("Scene exported as zip: %s", scene_id)
|
logger.info("Scene exported as zip: %s", scene_id)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
def import_scene_zip(self, zip_data: bytes) -> bool:
|
def import_scene_zip(self, zip_data: bytes) -> bool:
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
if "scene.json" not in zf.namelist():
|
if "manifest.json" not in zf.namelist():
|
||||||
logger.error("No scene.json found in zip")
|
logger.error("No manifest.json found in zip")
|
||||||
return False
|
return False
|
||||||
data = json.loads(zf.read("scene.json").decode('utf-8'))
|
data = json.loads(zf.read("manifest.json").decode('utf-8'))
|
||||||
scene = Scene.from_dict(data)
|
scene = Scene.from_dict(data)
|
||||||
return self.save(scene)
|
item = SCENES_DIR / scene.id
|
||||||
|
if item.exists():
|
||||||
|
import shutil; shutil.rmtree(item)
|
||||||
|
item.mkdir(parents=True)
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith('/'): continue
|
||||||
|
dest = item / name
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(zf.read(name))
|
||||||
|
logger.info("Scene imported from zip: %s", scene.id)
|
||||||
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to import scene zip: %s", e)
|
logger.error("Failed to import scene zip: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ websockets==14.1
|
|||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.18
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
+506
@@ -72,6 +72,18 @@ async def api_stop_telemetry():
|
|||||||
return {"ok": True, "running": telemetry_listener.is_running}
|
return {"ok": True, "running": telemetry_listener.is_running}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-mode/start")
|
||||||
|
async def api_start_test_mode():
|
||||||
|
telemetry_listener.start_test_mode()
|
||||||
|
return {"ok": True, "testing": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-mode/stop")
|
||||||
|
async def api_stop_test_mode():
|
||||||
|
telemetry_listener.stop_test_mode()
|
||||||
|
return {"ok": True, "testing": False}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/telemetry/latest")
|
@router.get("/telemetry/latest")
|
||||||
async def api_latest_telemetry():
|
async def api_latest_telemetry():
|
||||||
latest = telemetry_listener.latest_data
|
latest = telemetry_listener.latest_data
|
||||||
@@ -313,6 +325,87 @@ async def api_reload_game_plugins():
|
|||||||
return {"ok": True, "count": len(game_plugin_manager.list_all())}
|
return {"ok": True, "count": len(game_plugin_manager.list_all())}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Logs ----
|
||||||
|
@router.get("/logs")
|
||||||
|
async def api_get_logs(lines: int = 50):
|
||||||
|
from pathlib import Path
|
||||||
|
log_file = Path(__file__).resolve().parent.parent / "logs" / "turbosu.log"
|
||||||
|
if not log_file.exists():
|
||||||
|
return {"lines": []}
|
||||||
|
try:
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
return {"lines": [l.rstrip() for l in all_lines[-lines:]]}
|
||||||
|
except Exception:
|
||||||
|
return {"lines": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Full Backup (.tsb) ----
|
||||||
|
@router.get("/backup/export")
|
||||||
|
async def api_export_backup():
|
||||||
|
import io, zipfile
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
base = Path(__file__).resolve().parent.parent
|
||||||
|
data_dir = base / "data"
|
||||||
|
if data_dir.exists():
|
||||||
|
for f in data_dir.rglob("*"):
|
||||||
|
if f.is_file() and "__pycache__" not in str(f):
|
||||||
|
zf.write(f, f.relative_to(base))
|
||||||
|
dash_dir = base / "dashboards"
|
||||||
|
if dash_dir.exists():
|
||||||
|
for f in dash_dir.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
zf.write(f, f.relative_to(base))
|
||||||
|
games_dir = base / "games" / "user"
|
||||||
|
if games_dir.exists():
|
||||||
|
for f in games_dir.rglob("*"):
|
||||||
|
if f.is_file():
|
||||||
|
zf.write(f, f.relative_to(base))
|
||||||
|
|
||||||
|
return Response(content=buf.getvalue(), media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=turbosu_backup.tsb"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup/import")
|
||||||
|
async def api_import_backup(file: UploadFile = File(...)):
|
||||||
|
if not file.filename or not file.filename.endswith('.tsb'):
|
||||||
|
raise HTTPException(400, "Only .tsb files are accepted")
|
||||||
|
|
||||||
|
import io, zipfile, shutil
|
||||||
|
zip_data = await file.read()
|
||||||
|
base = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith('/') or '__pycache__' in name:
|
||||||
|
continue
|
||||||
|
dest = base / name
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_bytes(zf.read(name))
|
||||||
|
|
||||||
|
from config.settings import reload_config
|
||||||
|
reload_config()
|
||||||
|
from server.game_manager import game_plugin_manager
|
||||||
|
game_plugin_manager.reload()
|
||||||
|
|
||||||
|
return {"ok": True, "message": "备份已恢复,请重启服务"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Restart ----
|
||||||
|
@router.post("/restart")
|
||||||
|
async def api_restart():
|
||||||
|
import os, sys, atexit
|
||||||
|
logger.info("Restart requested via API")
|
||||||
|
atexit.register(lambda: os.execv(sys.executable, [sys.executable] + sys.argv))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
# ---- Data Forwarding ----
|
# ---- Data Forwarding ----
|
||||||
@router.get("/forward")
|
@router.get("/forward")
|
||||||
async def api_list_forward_targets():
|
async def api_list_forward_targets():
|
||||||
@@ -329,3 +422,416 @@ async def api_update_forward_targets(data: dict[str, Any]):
|
|||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
telemetry_listener.forwarder.reload_targets()
|
telemetry_listener.forwarder.reload_targets()
|
||||||
return {"ok": True, "active": telemetry_listener.forwarder.active}
|
return {"ok": True, "active": telemetry_listener.forwarder.active}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Record & Playback ----
|
||||||
|
from server.recorder import TelemetryRecorder, TelemetryPlayer
|
||||||
|
|
||||||
|
_recorder = None
|
||||||
|
_player = None
|
||||||
|
|
||||||
|
def _get_recorder():
|
||||||
|
global _recorder
|
||||||
|
if _recorder is None: _recorder = TelemetryRecorder(telemetry_listener)
|
||||||
|
return _recorder
|
||||||
|
|
||||||
|
def _get_player():
|
||||||
|
global _player
|
||||||
|
if _player is None: _player = TelemetryPlayer(telemetry_listener)
|
||||||
|
return _player
|
||||||
|
|
||||||
|
@router.post("/record/start")
|
||||||
|
async def api_start_record():
|
||||||
|
_get_recorder().start()
|
||||||
|
return {"ok": True, "recording": True}
|
||||||
|
|
||||||
|
@router.post("/record/stop")
|
||||||
|
async def api_stop_record():
|
||||||
|
_get_recorder().stop()
|
||||||
|
return {"ok": True, "recording": False}
|
||||||
|
|
||||||
|
@router.get("/record/status")
|
||||||
|
async def api_record_status():
|
||||||
|
return {"recording": _get_recorder().is_recording, "playing": _get_player().is_playing}
|
||||||
|
|
||||||
|
@router.get("/recordings")
|
||||||
|
async def api_list_recordings():
|
||||||
|
return TelemetryPlayer.list_recordings()
|
||||||
|
|
||||||
|
@router.post("/playback/start")
|
||||||
|
async def api_start_playback(data: dict):
|
||||||
|
filename = data.get("filename", "")
|
||||||
|
if not filename: raise HTTPException(400, "filename required")
|
||||||
|
_get_player().start(filename)
|
||||||
|
return {"ok": True, "playing": True}
|
||||||
|
|
||||||
|
@router.post("/playback/stop")
|
||||||
|
async def api_stop_playback():
|
||||||
|
_get_player().stop()
|
||||||
|
return {"ok": True, "playing": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recording/{filename}/export")
|
||||||
|
async def api_export_recording(filename: str):
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from server.recorder import RECORDINGS_DIR
|
||||||
|
path = RECORDINGS_DIR / filename
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "Recording not found")
|
||||||
|
return FileResponse(path, media_type="application/octet-stream", filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/recording/{filename}/delete")
|
||||||
|
async def api_delete_recording(filename: str):
|
||||||
|
from server.recorder import RECORDINGS_DIR
|
||||||
|
path = RECORDINGS_DIR / filename
|
||||||
|
if path.exists(): path.unlink()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Market Proxy ----
|
||||||
|
@router.get("/market/dashboards")
|
||||||
|
async def api_market_list():
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=50")
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/auth")
|
||||||
|
async def api_market_auth(data: dict):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/users/auth-with-password",
|
||||||
|
json={"identity": data.get("email", data.get("identity","")), "password": data.get("password")}
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return {"message": "服务器连接失败: " + str(e), "token": None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/register")
|
||||||
|
async def api_market_register(data: dict):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/users/records",
|
||||||
|
json={
|
||||||
|
"email": data.get("email"),
|
||||||
|
"password": data.get("password"),
|
||||||
|
"passwordConfirm": data.get("passwordConfirm"),
|
||||||
|
"name": data.get("name", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/upload")
|
||||||
|
async def api_market_upload(file: UploadFile = File(...), name: str = "", category: str = "", author: str = "", token: str = ""):
|
||||||
|
import httpx, io, zipfile, json as _json
|
||||||
|
|
||||||
|
# Validate .tsd
|
||||||
|
if not file.filename or not file.filename.endswith('.tsd'):
|
||||||
|
return {"error": "仅支持 .tsd 文件"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
zip_data = await file.read()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
if 'manifest.json' not in names:
|
||||||
|
return {"error": "无效的仪表盘文件:缺少 manifest.json"}
|
||||||
|
if 'index.html' not in names:
|
||||||
|
return {"error": "无效的仪表盘文件:缺少 index.html"}
|
||||||
|
manifest = _json.loads(zf.read('manifest.json').decode('utf-8'))
|
||||||
|
|
||||||
|
if not manifest.get('id'):
|
||||||
|
import uuid
|
||||||
|
manifest['id'] = str(uuid.uuid4())
|
||||||
|
if not manifest.get('name'):
|
||||||
|
return {"error": "manifest.json 缺少 name"}
|
||||||
|
|
||||||
|
import hashlib, io as _io
|
||||||
|
if not token:
|
||||||
|
return {"error": "需要登录"}
|
||||||
|
user_prefix = hashlib.md5(token.encode()).hexdigest()[:6]
|
||||||
|
manifest_id = f"{user_prefix}__{manifest['id']}"
|
||||||
|
manifest['id'] = manifest_id
|
||||||
|
|
||||||
|
# Rewrite zip with updated manifest.json
|
||||||
|
new_zip = _io.BytesIO()
|
||||||
|
with zipfile.ZipFile(new_zip, 'w', zipfile.ZIP_DEFLATED) as zf_out:
|
||||||
|
with zipfile.ZipFile(_io.BytesIO(zip_data), 'r') as zf_in:
|
||||||
|
for name in zf_in.namelist():
|
||||||
|
if name == 'manifest.json':
|
||||||
|
zf_out.writestr(name, _json.dumps(manifest, indent=2, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
zf_out.writestr(name, zf_in.read(name))
|
||||||
|
zip_data = new_zip.getvalue()
|
||||||
|
|
||||||
|
actual_name = name or manifest.get('name', file.filename.replace('.tsd', ''))
|
||||||
|
actual_category = category or manifest.get('category', 'community')
|
||||||
|
actual_author = author or manifest.get('author', 'Unknown')
|
||||||
|
actual_version = manifest.get('version', '1.0.0')
|
||||||
|
supported = manifest.get('supported_games', 'all')
|
||||||
|
icon = manifest.get('config', {}).get('icon', '📦')
|
||||||
|
desc = manifest.get('description', '')
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return {"error": "无效的 zip 文件"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"文件解析失败: {str(e)}"}
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/dashboards/records",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
data={
|
||||||
|
"name": actual_name, "category": actual_category, "author": actual_author,
|
||||||
|
"version": actual_version, "supported_games": supported,
|
||||||
|
"icon": icon, "description": desc, "dashb_pending": "true",
|
||||||
|
"manifest_id": manifest_id,
|
||||||
|
},
|
||||||
|
files={"file": (file.filename, zip_data, file.content_type)},
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/market/install")
|
||||||
|
async def api_market_install(id: str = "", filename: str = ""):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(f"{url}/api/files/dashboards/{id}/{filename}")
|
||||||
|
zip_data = resp.content
|
||||||
|
from models.dashboard import dashboard_manager
|
||||||
|
ok = dashboard_manager.import_theme_zip(zip_data)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client2:
|
||||||
|
# Get current downloads
|
||||||
|
r = await client2.get(f"{url}/api/collections/dashboards/records/{id}")
|
||||||
|
current = r.json().get("downloads", 0) or 0
|
||||||
|
# Increment
|
||||||
|
await client2.patch(
|
||||||
|
f"{url}/api/collections/dashboards/records/{id}",
|
||||||
|
json={"downloads": current + 1}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"ok": ok}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Market Scenes ----
|
||||||
|
@router.get("/market/scenes")
|
||||||
|
async def api_market_scenes():
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=50")
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/scenes/upload")
|
||||||
|
async def api_market_upload_scene(file: UploadFile = File(...), token: str = ""):
|
||||||
|
import io, zipfile, json as _json
|
||||||
|
if not file.filename or not file.filename.endswith('.tss'):
|
||||||
|
return {"error": "仅支持 .tss 文件"}
|
||||||
|
try:
|
||||||
|
zip_data = await file.read()
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
|
if 'manifest.json' not in zf.namelist():
|
||||||
|
return {"error": "缺少 manifest.json"}
|
||||||
|
scene = _json.loads(zf.read('manifest.json').decode('utf-8'))
|
||||||
|
if not scene.get('name'):
|
||||||
|
return {"error": "manifest.json 缺少 name"}
|
||||||
|
deps = set()
|
||||||
|
for canvas in scene.get('canvases', []):
|
||||||
|
for p in canvas.get('placements', []):
|
||||||
|
if p.get('dashboard_id'):
|
||||||
|
deps.add(p['dashboard_id'])
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return {"error": "无效的 zip 文件"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
import httpx as _httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with _httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/scenes/records",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
data={"name": scene.get('name'), "description": scene.get('description',''), "game_id": scene.get('game_id',''), "author": scene.get('author','') or "Unknown", "dashb_pending": True},
|
||||||
|
files={"file": (file.filename, zip_data, file.content_type)},
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
result["dependencies"] = list(deps)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/market/scenes/install")
|
||||||
|
async def api_market_install_scene(id: str = "", filename: str = ""):
|
||||||
|
import httpx, io, zipfile, json as _json
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(f"{url}/api/files/scenes/{id}/{filename}")
|
||||||
|
zip_data = resp.content
|
||||||
|
missing = []
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
|
if 'manifest.json' in zf.namelist():
|
||||||
|
scene = _json.loads(zf.read('manifest.json').decode('utf-8'))
|
||||||
|
from models.dashboard import dashboard_manager
|
||||||
|
for canvas in scene.get('canvases', []):
|
||||||
|
for p in canvas.get('placements', []):
|
||||||
|
did = p.get('dashboard_id', '')
|
||||||
|
if did and not dashboard_manager.get(did):
|
||||||
|
missing.append(did)
|
||||||
|
from models.scene import scene_manager
|
||||||
|
ok = scene_manager.import_scene_zip(zip_data)
|
||||||
|
return {"ok": ok, "missing_dashboards": missing}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/market/mine")
|
||||||
|
async def api_market_mine(token: str = ""):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
result = {"dashboards": [], "scenes": []}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=100",
|
||||||
|
headers={"Authorization": f"Bearer {token}"})
|
||||||
|
result["dashboards"] = r.json().get("items", [])
|
||||||
|
r = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=100",
|
||||||
|
headers={"Authorization": f"Bearer {token}"})
|
||||||
|
result["scenes"] = r.json().get("items", [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/delete")
|
||||||
|
async def api_market_delete(data: dict):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
collection = data.get("collection", "dashboards")
|
||||||
|
record_id = data.get("id", "")
|
||||||
|
token = data.get("token", "")
|
||||||
|
resp = await client.delete(
|
||||||
|
f"{url}/api/collections/{collection}/records/{record_id}",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
return {"ok": resp.status_code < 400}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/publish-local")
|
||||||
|
async def api_market_publish_local(data: dict):
|
||||||
|
import io, zipfile, json as _json
|
||||||
|
token = data.get("token", "")
|
||||||
|
dash_id = data.get("id", "")
|
||||||
|
pub_type = data.get("type", "dashboard")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return {"error": "需要登录"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from models.dashboard import dashboard_manager
|
||||||
|
from models.scene import scene_manager
|
||||||
|
|
||||||
|
if pub_type == "scene":
|
||||||
|
scene = scene_manager.get(dash_id)
|
||||||
|
if not scene:
|
||||||
|
return {"error": "场景不存在"}
|
||||||
|
if scene.get("is_builtin"):
|
||||||
|
return {"error": "不能发布内置场景"}
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("manifest.json", _json.dumps(scene, indent=2, ensure_ascii=False))
|
||||||
|
zip_data = buf.getvalue()
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/scenes/records",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
data={"name": scene.get("name",""), "description": scene.get("description",""),
|
||||||
|
"game_id": scene.get("game_id",""), "author": scene.get("author","") or "Unknown",
|
||||||
|
"dashb_pending": "true", "manifest_id": scene.get("id","")},
|
||||||
|
files={"file": (f"{dash_id}.tss", zip_data, "application/zip")},
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
else:
|
||||||
|
theme_dir = dashboard_manager.get_dir(dash_id)
|
||||||
|
if not theme_dir:
|
||||||
|
return {"error": "仪表盘不存在"}
|
||||||
|
theme = dashboard_manager.get(dash_id)
|
||||||
|
if not theme or theme.get("is_builtin"):
|
||||||
|
return {"error": "不能发布内置或市场仪表盘"}
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for f in sorted(theme_dir.rglob('*')):
|
||||||
|
if f.is_file():
|
||||||
|
zf.write(f, f.relative_to(theme_dir))
|
||||||
|
zip_data = buf.getvalue()
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "https://turbosu.yeij.top")
|
||||||
|
import httpx
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/dashboards/records",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
data={"name": theme.get("name",""), "category": theme.get("category",""),
|
||||||
|
"author": theme.get("author",""), "version": theme.get("version","1.0"),
|
||||||
|
"dashb_pending": "true", "manifest_id": theme.get("id",""),
|
||||||
|
"icon": theme.get("icon",""), "supported_games": theme.get("supported_games","all")},
|
||||||
|
files={"file": (f"{dash_id}.tsd", zip_data, "application/zip")},
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|||||||
+22
-4
@@ -68,11 +68,11 @@ class GamePluginManager:
|
|||||||
USER_DIR.mkdir(parents=True, exist_ok=True)
|
USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
self._plugins: dict[str, GamePlugin] = {}
|
self._plugins: dict[str, GamePlugin] = {}
|
||||||
self._parser_cache: dict[str, Any] = {}
|
self._parser_cache: dict[str, Any] = {}
|
||||||
|
self._scanned = False
|
||||||
self._discover()
|
self._discover()
|
||||||
|
|
||||||
def _discover(self):
|
def _discover(self):
|
||||||
self._plugins.clear()
|
self._plugins.clear()
|
||||||
|
|
||||||
for d in [BUILTIN_DIR, USER_DIR]:
|
for d in [BUILTIN_DIR, USER_DIR]:
|
||||||
if not d.exists():
|
if not d.exists():
|
||||||
continue
|
continue
|
||||||
@@ -84,14 +84,18 @@ class GamePluginManager:
|
|||||||
gp = GamePlugin.from_manifest(manifest, is_builtin)
|
gp = GamePlugin.from_manifest(manifest, is_builtin)
|
||||||
if gp:
|
if gp:
|
||||||
self._plugins[gp.id] = gp
|
self._plugins[gp.id] = gp
|
||||||
logger.debug("Discovered game plugin: %s", gp.id)
|
self._scanned = True
|
||||||
|
|
||||||
|
def _ensure_scanned(self):
|
||||||
|
if not self._scanned:
|
||||||
|
self._discover()
|
||||||
|
|
||||||
def list_all(self) -> list[dict[str, Any]]:
|
def list_all(self) -> list[dict[str, Any]]:
|
||||||
self._discover()
|
self._ensure_scanned()
|
||||||
return [p.to_dict() for p in self._plugins.values()]
|
return [p.to_dict() for p in self._plugins.values()]
|
||||||
|
|
||||||
def get(self, plugin_id: str) -> GamePlugin | None:
|
def get(self, plugin_id: str) -> GamePlugin | None:
|
||||||
self._discover()
|
self._ensure_scanned()
|
||||||
return self._plugins.get(plugin_id)
|
return self._plugins.get(plugin_id)
|
||||||
|
|
||||||
def get_parser(self, plugin_id: str) -> Any | None:
|
def get_parser(self, plugin_id: str) -> Any | None:
|
||||||
@@ -130,6 +134,20 @@ class GamePluginManager:
|
|||||||
self._discover()
|
self._discover()
|
||||||
self._parser_cache.clear()
|
self._parser_cache.clear()
|
||||||
|
|
||||||
|
def remove_plugin(self, plugin_id: str) -> bool:
|
||||||
|
self._discover()
|
||||||
|
gp = self._plugins.get(plugin_id)
|
||||||
|
if gp and gp.is_builtin:
|
||||||
|
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
|
||||||
|
return False
|
||||||
|
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 install_plugin_zip(self, zip_data: bytes) -> bool:
|
def install_plugin_zip(self, zip_data: bytes) -> bool:
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
RECORDINGS_DIR = Path(__file__).resolve().parent.parent / "data" / "recordings"
|
||||||
|
RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryRecorder:
|
||||||
|
def __init__(self, listener):
|
||||||
|
self._listener = listener
|
||||||
|
self._file = None
|
||||||
|
self._recording = False
|
||||||
|
self._auto_mode = False
|
||||||
|
self._start_time = 0.0
|
||||||
|
self._last_race_on = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_recording(self) -> bool:
|
||||||
|
return self._recording
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_file(self) -> str | None:
|
||||||
|
return str(self._file.name) if self._file else None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._recording:
|
||||||
|
return
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
self._file = open(RECORDINGS_DIR / f"rec_{ts}.tsr", "wb")
|
||||||
|
self._recording = True
|
||||||
|
self._start_time = time.time()
|
||||||
|
logger.info("Recording started: %s", self._file.name)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if not self._recording:
|
||||||
|
return
|
||||||
|
self._recording = False
|
||||||
|
if self._file:
|
||||||
|
self._file.close()
|
||||||
|
self._file = None
|
||||||
|
logger.info("Recording stopped")
|
||||||
|
|
||||||
|
def set_auto(self, enabled: bool):
|
||||||
|
self._auto_mode = enabled
|
||||||
|
self._last_race_on = False
|
||||||
|
if not enabled:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def write_packet(self, data: bytes):
|
||||||
|
if self._auto_mode:
|
||||||
|
try:
|
||||||
|
is_race = struct.unpack_from("<i", data, 0)[0] == 1
|
||||||
|
except Exception:
|
||||||
|
is_race = True
|
||||||
|
|
||||||
|
if is_race and not self._last_race_on:
|
||||||
|
self.start()
|
||||||
|
elif not is_race and self._last_race_on and self._recording:
|
||||||
|
self.stop()
|
||||||
|
self._last_race_on = is_race
|
||||||
|
|
||||||
|
if not self._recording or not self._file:
|
||||||
|
return
|
||||||
|
elapsed_ms = int((time.time() - self._start_time) * 1000)
|
||||||
|
self._file.write(struct.pack("<II", elapsed_ms, len(data)))
|
||||||
|
self._file.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryPlayer:
|
||||||
|
def __init__(self, listener):
|
||||||
|
self._listener = listener
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
self._playing = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return self._playing
|
||||||
|
|
||||||
|
def start(self, filename: str):
|
||||||
|
if self._playing:
|
||||||
|
return
|
||||||
|
path = RECORDINGS_DIR / filename
|
||||||
|
if not path.exists():
|
||||||
|
logger.error("Recording not found: %s", filename)
|
||||||
|
return
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._task = loop.create_task(self._play_loop(path))
|
||||||
|
self._playing = True
|
||||||
|
logger.info("Playback started: %s", filename)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._playing = False
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
logger.info("Playback stopped")
|
||||||
|
|
||||||
|
async def _play_loop(self, path: Path):
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
offset = 0
|
||||||
|
play_start = time.time()
|
||||||
|
while offset < len(data) and self._playing:
|
||||||
|
elapsed_ms, length = struct.unpack_from("<II", data, offset)
|
||||||
|
offset += 8
|
||||||
|
packet = data[offset:offset + length]
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
target_time = play_start + elapsed_ms / 1000.0
|
||||||
|
delay = target_time - time.time()
|
||||||
|
if delay > 0:
|
||||||
|
await asyncio.sleep(min(delay, 0.5))
|
||||||
|
|
||||||
|
self._listener._handle_packet(packet, ("127.0.0.1", 0))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Playback error: %s", e)
|
||||||
|
finally:
|
||||||
|
self._playing = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_recordings() -> list[dict]:
|
||||||
|
return [{"name": f.name, "size": f.stat().st_size, "time": f.stat().st_mtime}
|
||||||
|
for f in sorted(RECORDINGS_DIR.glob("*.tsr"), key=lambda x: x.stat().st_mtime, reverse=True)]
|
||||||
@@ -8,6 +8,7 @@ from typing import Any, Callable
|
|||||||
from config.settings import get_config
|
from config.settings import get_config
|
||||||
from server.telemetry.parsers import PARSER_MAP, BaseParser
|
from server.telemetry.parsers import PARSER_MAP, BaseParser
|
||||||
from server.telemetry.data import TelemetryData
|
from server.telemetry.data import TelemetryData
|
||||||
|
from server.telemetry.tcp_listener import TCPListener
|
||||||
from server.game_manager import game_plugin_manager
|
from server.game_manager import game_plugin_manager
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
@@ -50,7 +51,9 @@ class DataForwarder:
|
|||||||
class TelemetryListener:
|
class TelemetryListener:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._transport: asyncio.DatagramTransport | None = None
|
self._transport: asyncio.DatagramTransport | None = None
|
||||||
|
self._tcp: TCPListener | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self._current_protocol = "udp"
|
||||||
self._callbacks: list[Callable[[TelemetryData], None]] = []
|
self._callbacks: list[Callable[[TelemetryData], None]] = []
|
||||||
self._parser: BaseParser | None = None
|
self._parser: BaseParser | None = None
|
||||||
self._parser_cache: dict[str, BaseParser] = {}
|
self._parser_cache: dict[str, BaseParser] = {}
|
||||||
@@ -90,6 +93,48 @@ class TelemetryListener:
|
|||||||
host = cfg.get("telemetry_host", "0.0.0.0")
|
host = cfg.get("telemetry_host", "0.0.0.0")
|
||||||
port = cfg.get("telemetry_port", 20777)
|
port = cfg.get("telemetry_port", 20777)
|
||||||
|
|
||||||
|
selected_game = cfg.get("selected_game_id")
|
||||||
|
if selected_game:
|
||||||
|
self.set_parser_for_game(selected_game)
|
||||||
|
gp = game_plugin_manager.get(selected_game)
|
||||||
|
if gp and gp.manifest_path:
|
||||||
|
import json as _json
|
||||||
|
try:
|
||||||
|
with open(f"{gp.manifest_path}/manifest.json", "r") as f:
|
||||||
|
manifest = _json.load(f)
|
||||||
|
self._current_protocol = manifest.get("protocol", "udp")
|
||||||
|
except Exception:
|
||||||
|
self._current_protocol = "udp"
|
||||||
|
|
||||||
|
if self._current_protocol == "tcp":
|
||||||
|
import json as _json
|
||||||
|
mode = "server"
|
||||||
|
tcp_path = "/"
|
||||||
|
if selected_game:
|
||||||
|
gp = game_plugin_manager.get(selected_game)
|
||||||
|
if gp and gp.manifest_path:
|
||||||
|
try:
|
||||||
|
with open(f"{gp.manifest_path}/manifest.json", "r") as f:
|
||||||
|
m = _json.load(f)
|
||||||
|
mode = m.get("mode", "server")
|
||||||
|
tcp_path = m.get("path", "/")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if mode == "server":
|
||||||
|
self._tcp = TCPListener(self._handle_packet)
|
||||||
|
ok = await self._tcp.start(host, port)
|
||||||
|
if ok:
|
||||||
|
self._running = True
|
||||||
|
self.forwarder.reload_targets()
|
||||||
|
logger.info("TCP server started on %s:%d [game=%s]", host, port, selected_game or "none")
|
||||||
|
return ok
|
||||||
|
else:
|
||||||
|
# TCP client mode: plugin handles its own connection
|
||||||
|
logger.info("TCP client mode - plugin handles connection [game=%s]", selected_game or "none")
|
||||||
|
self._running = True
|
||||||
|
return True
|
||||||
|
else:
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
self._transport, _ = await loop.create_datagram_endpoint(
|
self._transport, _ = await loop.create_datagram_endpoint(
|
||||||
@@ -98,25 +143,43 @@ class TelemetryListener:
|
|||||||
)
|
)
|
||||||
self._running = True
|
self._running = True
|
||||||
self.forwarder.reload_targets()
|
self.forwarder.reload_targets()
|
||||||
|
logger.info("UDP listener started on %s:%d [game=%s]", host, port, selected_game or "none")
|
||||||
selected_game = cfg.get("selected_game_id")
|
|
||||||
if selected_game:
|
|
||||||
self.set_parser_for_game(selected_game)
|
|
||||||
|
|
||||||
logger.info("Telemetry listener started on %s:%d [game=%s]",
|
|
||||||
host, port, selected_game or "none")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to start telemetry listener: %s", e)
|
logger.error("Failed to start UDP listener: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
|
self.stop_test_mode()
|
||||||
if self._transport:
|
if self._transport:
|
||||||
self._transport.close()
|
self._transport.close()
|
||||||
self._transport = None
|
self._transport = None
|
||||||
|
if self._tcp:
|
||||||
|
asyncio.ensure_future(self._tcp.stop())
|
||||||
|
self._tcp = None
|
||||||
|
if self.forwarder._sock:
|
||||||
|
self.forwarder._sock.close()
|
||||||
|
self.forwarder._sock = None
|
||||||
logger.info("Telemetry listener stopped")
|
logger.info("Telemetry listener stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_testing(self) -> bool:
|
||||||
|
from server.test_mode import get_generator
|
||||||
|
gen = get_generator()
|
||||||
|
return gen is not None and gen.running
|
||||||
|
|
||||||
|
def start_test_mode(self):
|
||||||
|
from server.test_mode import get_generator
|
||||||
|
gen = get_generator(self)
|
||||||
|
gen.start()
|
||||||
|
|
||||||
|
def stop_test_mode(self):
|
||||||
|
from server.test_mode import get_generator
|
||||||
|
gen = get_generator()
|
||||||
|
if gen is not None and gen.running:
|
||||||
|
gen.stop()
|
||||||
|
|
||||||
def _get_parser(self, key: str) -> BaseParser:
|
def _get_parser(self, key: str) -> BaseParser:
|
||||||
if key not in self._parser_cache:
|
if key not in self._parser_cache:
|
||||||
cls = PARSER_MAP.get(key)
|
cls = PARSER_MAP.get(key)
|
||||||
@@ -130,6 +193,10 @@ class TelemetryListener:
|
|||||||
|
|
||||||
self.forwarder.forward(data)
|
self.forwarder.forward(data)
|
||||||
|
|
||||||
|
from server.api import _recorder
|
||||||
|
if _recorder and _recorder.is_recording:
|
||||||
|
_recorder.write_packet(data)
|
||||||
|
|
||||||
td = None
|
td = None
|
||||||
if self._parser:
|
if self._parser:
|
||||||
td = self._parser.parse(data, addr)
|
td = self._parser.parse(data, addr)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TCPListener:
|
||||||
|
def __init__(self, handle_packet):
|
||||||
|
self._server: asyncio.Server | None = None
|
||||||
|
self._handle_packet = handle_packet
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
async def start(self, host: str, port: int) -> bool:
|
||||||
|
if self._running:
|
||||||
|
await self.stop()
|
||||||
|
try:
|
||||||
|
self._server = await asyncio.start_server(
|
||||||
|
self._on_client_connected, host, port
|
||||||
|
)
|
||||||
|
self._running = True
|
||||||
|
logger.info("TCP listener started on %s:%d", host, port)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start TCP listener: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self._running = False
|
||||||
|
if self._server:
|
||||||
|
self._server.close()
|
||||||
|
await self._server.wait_closed()
|
||||||
|
self._server = None
|
||||||
|
logger.info("TCP listener stopped")
|
||||||
|
|
||||||
|
async def _on_client_connected(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
||||||
|
addr = writer.get_extra_info('peername')
|
||||||
|
logger.debug("TCP client connected: %s", addr)
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
data = await reader.read(65536)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
self._handle_packet(data, addr)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("TCP client disconnected: %s - %s", addr, e)
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataGenerator:
|
||||||
|
def __init__(self, listener):
|
||||||
|
self._listener = listener
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._task is not None and not self._task.done()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.running:
|
||||||
|
return
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self._task = loop.create_task(self._loop())
|
||||||
|
logger.info("Test data generator started")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._task and not self._task.done():
|
||||||
|
self._task.cancel()
|
||||||
|
self._task = None
|
||||||
|
logger.info("Test data generator stopped")
|
||||||
|
|
||||||
|
async def _loop(self):
|
||||||
|
phase = 0.0
|
||||||
|
gear = 1
|
||||||
|
gear_timer = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(1.0 / 30.0)
|
||||||
|
phase += 0.06
|
||||||
|
gear_timer += 1
|
||||||
|
if gear_timer > 55:
|
||||||
|
gear_timer = 0
|
||||||
|
gear = (gear % 6) + 1
|
||||||
|
|
||||||
|
max_rpm = 8000.0
|
||||||
|
rpm = max_rpm * (0.12 + 0.58 * abs(math.sin(phase)))
|
||||||
|
speed_mps = rpm / max_rpm * 89.0 + random.uniform(-1, 1)
|
||||||
|
throttle = max(0.0, min(1.0, 0.1 + 0.7 * abs(math.sin(phase + 0.5))))
|
||||||
|
brake = max(0.0, min(1.0, random.uniform(0, 0.35) if random.random() > 0.78 else 0))
|
||||||
|
steering = math.sin(phase * 0.25) * 0.45
|
||||||
|
fuel = max(0.0, 1.0 - phase * 0.007)
|
||||||
|
best_lap = 92.0 + random.uniform(-0.8, 0.8)
|
||||||
|
last_lap = best_lap + random.uniform(0.3, 2.0)
|
||||||
|
lap_time = last_lap * random.uniform(0.15, 0.80)
|
||||||
|
boost = random.uniform(0.2, 1.5)
|
||||||
|
hp = rpm / max_rpm * 420.0
|
||||||
|
torque = rpm / max_rpm * 520.0
|
||||||
|
|
||||||
|
buf = self._build_packet(max_rpm, rpm, speed_mps, hp, torque,
|
||||||
|
boost, fuel, best_lap, last_lap,
|
||||||
|
lap_time, int(phase) % 15,
|
||||||
|
throttle, brake, gear, steering)
|
||||||
|
|
||||||
|
self._listener._handle_packet(buf, ("127.0.0.1", 0))
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Test data error: %s", e)
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_packet(max_rpm, rpm, speed_mps, hp, torque, boost, fuel,
|
||||||
|
best_lap, last_lap, lap_time, lap_num,
|
||||||
|
throttle, brake, gear, steering):
|
||||||
|
buf = bytearray(324)
|
||||||
|
struct.pack_into("<i", buf, 0, 1)
|
||||||
|
struct.pack_into("<I", buf, 4, int(time.time() * 1000) & 0xFFFFFFFF)
|
||||||
|
struct.pack_into("<f", buf, 8, max_rpm)
|
||||||
|
struct.pack_into("<f", buf, 12, 800.0)
|
||||||
|
struct.pack_into("<f", buf, 16, rpm)
|
||||||
|
struct.pack_into("<f", buf, 244, random.uniform(-5000, 5000))
|
||||||
|
struct.pack_into("<f", buf, 248, random.uniform(-50, 50))
|
||||||
|
struct.pack_into("<f", buf, 252, random.uniform(-5000, 5000))
|
||||||
|
struct.pack_into("<f", buf, 20, (steering * 9.81 * 2.5) + random.uniform(-1.2, 1.2)) # lateral G + jitter
|
||||||
|
struct.pack_into("<f", buf, 24, random.uniform(-0.5, 0.5)) # vertical bounce
|
||||||
|
struct.pack_into("<f", buf, 28, ((throttle - brake) * 9.81 * 2) + random.uniform(-1.5, 1.5)) # longitudinal G + jitter
|
||||||
|
struct.pack_into("<f", buf, 256, speed_mps)
|
||||||
|
struct.pack_into("<f", buf, 260, hp * 745.7)
|
||||||
|
struct.pack_into("<f", buf, 264, torque)
|
||||||
|
struct.pack_into("<f", buf, 284, boost)
|
||||||
|
struct.pack_into("<f", buf, 288, fuel)
|
||||||
|
struct.pack_into("<f", buf, 296, best_lap)
|
||||||
|
struct.pack_into("<f", buf, 300, last_lap)
|
||||||
|
struct.pack_into("<f", buf, 304, lap_time)
|
||||||
|
struct.pack_into("<H", buf, 312, lap_num)
|
||||||
|
struct.pack_into("<B", buf, 314, 1)
|
||||||
|
struct.pack_into("<B", buf, 315, max(0, min(255, int(throttle * 255))))
|
||||||
|
struct.pack_into("<B", buf, 316, max(0, min(255, int(brake * 255))))
|
||||||
|
struct.pack_into("<B", buf, 319, gear)
|
||||||
|
struct.pack_into("<b", buf, 320, max(-127, min(127, int(steering * 127))))
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
_test_generator: TestDataGenerator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_generator(listener=None) -> TestDataGenerator:
|
||||||
|
global _test_generator
|
||||||
|
if _test_generator is None:
|
||||||
|
_test_generator = TestDataGenerator(listener)
|
||||||
|
elif listener is not None:
|
||||||
|
_test_generator._listener = listener
|
||||||
|
return _test_generator
|
||||||
+47
-1
@@ -27,10 +27,17 @@
|
|||||||
}
|
}
|
||||||
.sidebar.collapsed .nav-label,
|
.sidebar.collapsed .nav-label,
|
||||||
.sidebar.collapsed .sidebar-title,
|
.sidebar.collapsed .sidebar-title,
|
||||||
|
.sidebar.collapsed .sidebar-logo,
|
||||||
.sidebar.collapsed .collapse-arrow {
|
.sidebar.collapsed .collapse-arrow {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sidebar.collapsed .sidebar-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 8px;
|
||||||
}
|
}
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -493,6 +500,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: move;
|
cursor: move;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -500,7 +508,24 @@
|
|||||||
background: var(--accent-container);
|
background: var(--accent-container);
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.ep-icon { font-size: 24px; }
|
||||||
|
.ep-name { font-size: 11px; color: var(--text-secondary); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90%; }
|
||||||
|
.ep-actions { position: absolute; top: 2px; right: 2px; }
|
||||||
|
|
||||||
|
.editor-dash-item:active { opacity: 0.7; }
|
||||||
|
|
||||||
|
.forward-toggle input { margin-right: 4px; }
|
||||||
|
|
||||||
|
/* HyperOS Toggle Switch */
|
||||||
|
.switch-label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||||
|
.switch-label input { display: none; }
|
||||||
|
.switch-track { position: relative; width: 44px; height: 26px; background: var(--bg-tertiary); border-radius: 13px; border: 1px solid var(--border-normal); transition: background .2s, border-color .2s; flex-shrink: 0; }
|
||||||
|
.switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
|
||||||
|
.switch-label input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.switch-label input:checked + .switch-track::after { transform: translateX(18px); }
|
||||||
|
|
||||||
/* ===== Debug Page ===== */
|
/* ===== Debug Page ===== */
|
||||||
.debug-container {
|
.debug-container {
|
||||||
@@ -683,6 +708,16 @@ code {
|
|||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 200;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.sidebar.mobile-open {
|
||||||
|
display: flex;
|
||||||
|
box-shadow: 4px 0 24px rgba(0,0,0,.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
@@ -693,6 +728,9 @@ code {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
#mobile-menu-btn {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
.topbar-logo-title.hidden {
|
.topbar-logo-title.hidden {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -861,3 +899,11 @@ code {
|
|||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Settings Sub-Sidebar ---- */
|
||||||
|
.settings-layout { display: flex; gap: 20px; height: 100%; }
|
||||||
|
.settings-nav { width: 130px; flex-shrink: 0; overflow-y: auto; position: sticky; top: 0; padding-top: 8px; border-right: 1px solid var(--border-subtle); }
|
||||||
|
.settings-nav-item { display: block; padding: 8px 12px; border-radius: var(--radius-md); color: var(--text-secondary); text-decoration: none; font-size: 13px; transition: all var(--transition-fast); margin-bottom: 2px; }
|
||||||
|
.settings-nav-item:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
|
||||||
|
.settings-nav-item.active { background: var(--accent-light); color: var(--accent); font-weight: 500; }
|
||||||
|
.settings-main { flex: 1; overflow-y: auto; min-width: 0; }
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ html, body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
transition: background var(--transition-normal), color var(--transition-normal);
|
transition: background var(--transition-normal), color var(--transition-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="2119" height="2119">
|
||||||
|
<path d="M0,0 L248,1 L481,2 L516,3 L515,8 L505,17 L498,24 L490,31 L464,57 L459,62 L456,63 L454,67 L446,74 L442,78 L440,78 L440,80 L432,87 L424,95 L416,102 L407,111 L405,111 L405,113 L397,120 L381,135 L373,143 L365,150 L354,161 L346,168 L333,181 L325,188 L314,199 L306,206 L298,214 L293,219 L290,222 L288,222 L288,224 L30,224 L0,223 L-1,222 L-1,1 Z " fill="#000000" transform="translate(484,438)"/>
|
||||||
|
<path d="M0,0 L233,0 L238,5 L243,9 L243,11 L247,13 L257,21 L272,34 L286,46 L296,55 L307,64 L321,76 L332,85 L343,95 L349,100 L349,102 L354,104 L369,118 L379,126 L392,138 L400,145 L411,154 L424,166 L431,172 L431,174 L435,176 L443,182 L448,187 L457,195 L460,197 L460,376 L297,376 L234,375 L233,374 L232,296 L224,290 L224,288 L220,286 L218,282 L214,280 L199,266 L186,254 L172,240 L162,232 L162,230 L158,228 L154,224 L-77,224 L-86,233 L-94,240 L-104,249 L-109,254 L-113,259 L-121,266 L-133,277 L-141,284 L-155,298 L-157,298 L-157,577 L-159,1213 L-160,1239 L-223,1240 L-358,1239 L-395,1238 L-396,1237 L-396,914 L-395,360 L-394,302 L-369,277 L-368,275 L-366,275 L-364,271 L-355,262 L-350,257 L-343,251 L-336,243 L-329,236 L-322,228 L-285,191 L-278,183 L-275,180 L-273,180 L-273,178 L-271,178 L-269,174 L-264,169 L-262,169 L-262,167 L-257,163 L-250,155 L-241,146 L-240,144 L-238,144 L-236,140 L-224,128 L-216,121 L-201,105 L-193,97 L-186,89 L-173,76 L-170,72 L-168,72 L-168,70 L-164,66 L-162,66 L-160,62 L-148,50 L-141,42 L-137,40 L-136,37 L-134,37 L-132,33 L-127,29 L-122,24 L-111,12 L-104,4 L-102,1 Z M158,105 L63,106 L19,107 L-5,108 L-108,110 L-109,113 L-97,115 L-60,116 L174,118 L199,119 L201,123 L206,128 L214,135 L218,139 L220,139 L222,143 L233,153 L235,153 L236,156 L247,165 L260,177 L268,184 L279,194 L290,203 L298,211 L308,219 L320,230 L331,239 L342,249 L353,258 L359,263 L364,268 L367,269 L367,271 L370,273 L373,273 L372,267 L365,256 L354,244 L350,239 L339,230 L322,214 L312,205 L304,198 L292,187 L284,180 L272,169 L262,160 L251,150 L241,142 L235,136 L233,136 L232,133 L224,126 L214,117 L205,110 L200,106 L198,105 Z " fill="#000000" transform="translate(1173,440)"/>
|
||||||
|
<path d="M0,0 L7,4 L21,15 L29,21 L40,29 L55,40 L57,41 L57,43 L62,45 L71,52 L82,61 L93,69 L106,80 L117,88 L131,98 L138,104 L147,110 L156,118 L167,126 L182,137 L192,145 L198,149 L203,154 L213,161 L223,169 L232,175 L245,186 L261,198 L277,211 L295,224 L307,234 L315,240 L329,251 L344,262 L349,266 L351,267 L351,269 L355,271 L364,277 L369,281 L369,283 L374,285 L387,295 L400,305 L416,318 L422,322 L422,324 L426,326 L442,338 L453,347 L464,355 L477,365 L486,372 L499,382 L513,392 L522,399 L533,407 L543,415 L546,417 L547,530 L547,722 L543,726 L533,734 L522,743 L511,752 L501,761 L493,768 L480,780 L470,788 L460,797 L449,806 L441,813 L433,820 L417,833 L405,844 L395,852 L384,861 L380,864 L378,864 L377,867 L366,876 L356,885 L345,894 L335,902 L324,912 L317,918 L245,918 L2,916 L-3,915 L-3,748 L-2,698 L104,698 L209,699 L241,699 L257,684 L269,673 L285,658 L299,645 L308,638 L313,633 L318,628 L318,538 L315,535 L304,526 L294,518 L284,511 L273,503 L262,494 L245,481 L226,467 L212,456 L202,448 L189,438 L173,426 L164,420 L151,410 L142,403 L129,393 L112,381 L103,374 L90,364 L74,352 L61,342 L48,332 L34,321 L16,307 L5,299 L0,296 L-1,154 L1,153 L7,157 L23,169 L36,179 L53,192 L66,202 L79,212 L92,222 L100,228 L116,240 L126,248 L135,255 L153,269 L167,280 L176,287 L186,295 L203,308 L224,324 L240,337 L252,346 L266,357 L275,364 L293,377 L309,389 L322,400 L336,411 L345,418 L356,426 L367,435 L380,445 L393,455 L404,464 L416,473 L421,477 L421,676 L410,686 L399,695 L389,704 L381,711 L372,719 L364,726 L354,735 L346,742 L336,751 L319,765 L307,776 L299,783 L296,786 L294,786 L292,790 L284,797 L275,804 L82,805 L44,806 L61,807 L122,809 L258,812 L284,812 L292,804 L306,793 L317,783 L328,774 L342,761 L344,761 L345,758 L353,751 L367,739 L382,726 L393,716 L404,707 L412,700 L426,689 L433,684 L433,468 L423,462 L410,452 L393,438 L376,426 L365,417 L351,406 L337,395 L326,386 L313,376 L303,368 L287,356 L275,347 L259,334 L243,322 L227,310 L216,301 L205,293 L205,291 L201,289 L182,275 L169,265 L155,254 L142,244 L135,239 L130,234 L116,223 L98,210 L90,203 L81,196 L74,190 L61,180 L47,169 L33,159 L22,150 L11,141 L0,133 L-1,1 Z " fill="#000000" transform="translate(1086,764)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="2119" height="2119">
|
||||||
|
<path d="M0,0 L248,1 L481,2 L516,3 L515,8 L505,17 L498,24 L490,31 L464,57 L459,62 L456,63 L454,67 L446,74 L442,78 L440,78 L440,80 L432,87 L424,95 L416,102 L407,111 L405,111 L405,113 L397,120 L381,135 L373,143 L365,150 L354,161 L346,168 L333,181 L325,188 L314,199 L306,206 L298,214 L293,219 L290,222 L288,222 L288,224 L30,224 L0,223 L-1,222 L-1,1 Z " fill="#000000" transform="translate(484,438)"/>
|
||||||
|
<path d="M0,0 L233,0 L238,5 L243,9 L243,11 L247,13 L257,21 L272,34 L286,46 L296,55 L307,64 L321,76 L332,85 L343,95 L349,100 L349,102 L354,104 L369,118 L379,126 L392,138 L400,145 L411,154 L424,166 L431,172 L431,174 L435,176 L443,182 L448,187 L457,195 L460,197 L460,376 L297,376 L234,375 L233,374 L232,296 L224,290 L224,288 L220,286 L218,282 L214,280 L199,266 L186,254 L172,240 L162,232 L162,230 L158,228 L154,224 L-77,224 L-86,233 L-94,240 L-104,249 L-109,254 L-113,259 L-121,266 L-133,277 L-141,284 L-155,298 L-157,298 L-157,577 L-159,1213 L-160,1239 L-223,1240 L-358,1239 L-395,1238 L-396,1237 L-396,914 L-395,360 L-394,302 L-369,277 L-368,275 L-366,275 L-364,271 L-355,262 L-350,257 L-343,251 L-336,243 L-329,236 L-322,228 L-285,191 L-278,183 L-275,180 L-273,180 L-273,178 L-271,178 L-269,174 L-264,169 L-262,169 L-262,167 L-257,163 L-250,155 L-241,146 L-240,144 L-238,144 L-236,140 L-224,128 L-216,121 L-201,105 L-193,97 L-186,89 L-173,76 L-170,72 L-168,72 L-168,70 L-164,66 L-162,66 L-160,62 L-148,50 L-141,42 L-137,40 L-136,37 L-134,37 L-132,33 L-127,29 L-122,24 L-111,12 L-104,4 L-102,1 Z M158,105 L63,106 L19,107 L-5,108 L-108,110 L-109,113 L-97,115 L-60,116 L174,118 L199,119 L201,123 L206,128 L214,135 L218,139 L220,139 L222,143 L233,153 L235,153 L236,156 L247,165 L260,177 L268,184 L279,194 L290,203 L298,211 L308,219 L320,230 L331,239 L342,249 L353,258 L359,263 L364,268 L367,269 L367,271 L370,273 L373,273 L372,267 L365,256 L354,244 L350,239 L339,230 L322,214 L312,205 L304,198 L292,187 L284,180 L272,169 L262,160 L251,150 L241,142 L235,136 L233,136 L232,133 L224,126 L214,117 L205,110 L200,106 L198,105 Z " fill="#000000" transform="translate(1173,440)"/>
|
||||||
|
<path d="M0,0 L7,4 L21,15 L29,21 L40,29 L55,40 L57,41 L57,43 L62,45 L71,52 L82,61 L93,69 L106,80 L117,88 L131,98 L138,104 L147,110 L156,118 L167,126 L182,137 L192,145 L198,149 L203,154 L213,161 L223,169 L232,175 L245,186 L261,198 L277,211 L295,224 L307,234 L315,240 L329,251 L344,262 L349,266 L351,267 L351,269 L355,271 L364,277 L369,281 L369,283 L374,285 L387,295 L400,305 L416,318 L422,322 L422,324 L426,326 L442,338 L453,347 L464,355 L477,365 L486,372 L499,382 L513,392 L522,399 L533,407 L543,415 L546,417 L547,530 L547,722 L543,726 L533,734 L522,743 L511,752 L501,761 L493,768 L480,780 L470,788 L460,797 L449,806 L441,813 L433,820 L417,833 L405,844 L395,852 L384,861 L380,864 L378,864 L377,867 L366,876 L356,885 L345,894 L335,902 L324,912 L317,918 L245,918 L2,916 L-3,915 L-3,748 L-2,698 L104,698 L209,699 L241,699 L257,684 L269,673 L285,658 L299,645 L308,638 L313,633 L318,628 L318,538 L315,535 L304,526 L294,518 L284,511 L273,503 L262,494 L245,481 L226,467 L212,456 L202,448 L189,438 L173,426 L164,420 L151,410 L142,403 L129,393 L112,381 L103,374 L90,364 L74,352 L61,342 L48,332 L34,321 L16,307 L5,299 L0,296 L-1,154 L1,153 L7,157 L23,169 L36,179 L53,192 L66,202 L79,212 L92,222 L100,228 L116,240 L126,248 L135,255 L153,269 L167,280 L176,287 L186,295 L203,308 L224,324 L240,337 L252,346 L266,357 L275,364 L293,377 L309,389 L322,400 L336,411 L345,418 L356,426 L367,435 L380,445 L393,455 L404,464 L416,473 L421,477 L421,676 L410,686 L399,695 L389,704 L381,711 L372,719 L364,726 L354,735 L346,742 L336,751 L319,765 L307,776 L299,783 L296,786 L294,786 L292,790 L284,797 L275,804 L82,805 L44,806 L61,807 L122,809 L258,812 L284,812 L292,804 L306,793 L317,783 L328,774 L342,761 L344,761 L345,758 L353,751 L367,739 L382,726 L393,716 L404,707 L412,700 L426,689 L433,684 L433,468 L423,462 L410,452 L393,438 L376,426 L365,417 L351,406 L337,395 L326,386 L313,376 L303,368 L287,356 L275,347 L259,334 L243,322 L227,310 L216,301 L205,293 L205,291 L201,289 L182,275 L169,265 L155,254 L142,244 L135,239 L130,234 L116,223 L98,210 L90,203 L81,196 L74,190 L61,180 L47,169 L33,159 L22,150 L11,141 L0,133 L-1,1 Z " fill="#000000" transform="translate(1086,764)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.2 KiB |
+15
-24
@@ -1,33 +1,24 @@
|
|||||||
const API = {
|
const API = {
|
||||||
_base: '/api',
|
_base: '/api',
|
||||||
|
|
||||||
async get(path) {
|
async _req(method, path, body) {
|
||||||
const res = await fetch(`${this._base}${path}`);
|
const opts = { method };
|
||||||
|
if (body) {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${this._base}${path}`, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${method} ${path} ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async post(path, data) {
|
async get(path) { return this._req('GET', path); },
|
||||||
const res = await fetch(`${this._base}${path}`, {
|
async post(path, data) { return this._req('POST', path, data); },
|
||||||
method: 'POST',
|
async put(path, data) { return this._req('PUT', path, data); },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
async del(path) { return this._req('DELETE', path); },
|
||||||
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 getStatus() { return this.get('/status'); },
|
||||||
async getConfig() { return this.get('/config'); },
|
async getConfig() { return this.get('/config'); },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
Lang.init();
|
||||||
Theme.init();
|
Theme.init();
|
||||||
WS.init();
|
WS.init();
|
||||||
Sidebar.init();
|
Sidebar.init();
|
||||||
@@ -7,10 +8,13 @@
|
|||||||
PageDashboard.init();
|
PageDashboard.init();
|
||||||
PageScene.init();
|
PageScene.init();
|
||||||
PageDebug.init();
|
PageDebug.init();
|
||||||
|
PageRecord.init();
|
||||||
|
PageMarket.init();
|
||||||
PageSettings.init();
|
PageSettings.init();
|
||||||
Router.init();
|
Router.init();
|
||||||
|
|
||||||
document.getElementById('theme-toggle').addEventListener('click', () => Theme.toggle());
|
document.getElementById('theme-toggle').addEventListener('click', () => Theme.toggle());
|
||||||
|
document.getElementById('lang-toggle').addEventListener('click', () => Lang.toggle());
|
||||||
|
|
||||||
console.log('%cTurboSu %cReady',
|
console.log('%cTurboSu %cReady',
|
||||||
'color:#667eea;font-size:16px;font-weight:bold;',
|
'color:#667eea;font-size:16px;font-weight:bold;',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const Sidebar = {
|
|||||||
_gameListEl: null,
|
_gameListEl: null,
|
||||||
_gameToggleEl: null,
|
_gameToggleEl: null,
|
||||||
_gameLabelEl: null,
|
_gameLabelEl: null,
|
||||||
|
_gameData: [],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._el = document.getElementById('sidebar');
|
this._el = document.getElementById('sidebar');
|
||||||
@@ -44,6 +45,7 @@ const Sidebar = {
|
|||||||
|
|
||||||
async _loadGames() {
|
async _loadGames() {
|
||||||
const games = await API.getGames();
|
const games = await API.getGames();
|
||||||
|
this._gameData = games;
|
||||||
this._gameListEl.innerHTML = '';
|
this._gameListEl.innerHTML = '';
|
||||||
|
|
||||||
if (!Array.isArray(games) || games.length === 0) {
|
if (!Array.isArray(games) || games.length === 0) {
|
||||||
@@ -80,11 +82,17 @@ const Sidebar = {
|
|||||||
|
|
||||||
async _selectGame(gameId) {
|
async _selectGame(gameId) {
|
||||||
await API.updateConfig({ selected_game_id: gameId });
|
await API.updateConfig({ selected_game_id: gameId });
|
||||||
|
const cfg = await API.getConfig();
|
||||||
|
if (!cfg.use_unified_port) {
|
||||||
|
const gamePort = (cfg.game_ports || {})[gameId] || this._gameData.find(g => g.id === gameId)?.default_port || 20777;
|
||||||
|
await API.updateConfig({ telemetry_port: gamePort });
|
||||||
|
}
|
||||||
|
await API.stopTelemetry();
|
||||||
await API.startTelemetry();
|
await API.startTelemetry();
|
||||||
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
||||||
const items = this._gameListEl.querySelectorAll('.nav-item');
|
const items = this._gameListEl.querySelectorAll('.nav-item');
|
||||||
items.forEach(item => {
|
items.forEach((item, idx) => {
|
||||||
if (item.textContent.trim()) item.classList.add('active');
|
if (this._gameData[idx] && this._gameData[idx].id === gameId) item.classList.add('active');
|
||||||
});
|
});
|
||||||
const games = await API.getGames();
|
const games = await API.getGames();
|
||||||
this._updateGameLabel(gameId, games);
|
this._updateGameLabel(gameId, games);
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ const Topbar = {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._statusEl = document.getElementById('connection-status');
|
this._statusEl = document.getElementById('connection-status');
|
||||||
|
this._statusEl.style.cursor = 'pointer';
|
||||||
|
this._statusEl.addEventListener('click', () => {
|
||||||
|
if (!WS.connected) {
|
||||||
|
WS._reconnectDelay = 500;
|
||||||
|
WS._connect();
|
||||||
|
Toast.show('正在重连...', 'info', 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
WS.on('status', (status) => {
|
WS.on('status', (status) => {
|
||||||
this._statusEl.className = `status-indicator ${status}`;
|
this._statusEl.className = `status-indicator ${status}`;
|
||||||
@@ -21,7 +29,7 @@ const Topbar = {
|
|||||||
this._statusEl.className = `status-indicator ${wsStatus}`;
|
this._statusEl.className = `status-indicator ${wsStatus}`;
|
||||||
this._statusEl.querySelector('.status-text').textContent =
|
this._statusEl.querySelector('.status-text').textContent =
|
||||||
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接';
|
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接';
|
||||||
} catch (e) {}
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+192
-38
@@ -2,9 +2,12 @@ const PageDashboard = {
|
|||||||
_el: null,
|
_el: null,
|
||||||
_currentCategory: 'all',
|
_currentCategory: 'all',
|
||||||
_selectedGameId: null,
|
_selectedGameId: null,
|
||||||
|
_marketMode: false,
|
||||||
|
_marketToken: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._el = document.getElementById('page-dashboard');
|
this._el = document.getElementById('page-dashboard');
|
||||||
|
this._marketToken = localStorage.getItem('turbosu-market-token');
|
||||||
},
|
},
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
@@ -43,30 +46,48 @@ const PageDashboard = {
|
|||||||
async _loadThemes() {
|
async _loadThemes() {
|
||||||
const gridEl = document.getElementById('dash-theme-grid');
|
const gridEl = document.getElementById('dash-theme-grid');
|
||||||
if (!gridEl) return;
|
if (!gridEl) return;
|
||||||
|
|
||||||
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
||||||
|
|
||||||
|
if (this._marketMode) {
|
||||||
|
await this._loadMarket(gridEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let themes = await API.getDashboards(this._currentCategory);
|
let themes = await API.getDashboards(this._currentCategory);
|
||||||
if (!themes || themes.length === 0) {
|
if (!themes || themes.length === 0) { gridEl.innerHTML = this._emptyHtml(); return; }
|
||||||
gridEl.innerHTML = this._emptyHtml();
|
if (this._selectedGameId) { themes = themes.filter(t => this._isGameSupported(t)); }
|
||||||
return;
|
const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || '';
|
||||||
}
|
if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); }
|
||||||
|
const sortBy = document.getElementById('dash-sort')?.value || 'name';
|
||||||
if (this._selectedGameId) {
|
themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
|
||||||
themes = themes.filter(t => this._isGameSupported(t));
|
if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘'); return; }
|
||||||
}
|
|
||||||
|
|
||||||
if (themes.length === 0) {
|
|
||||||
gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
||||||
} catch (e) {
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; }
|
||||||
console.error('Failed to load themes:', e);
|
},
|
||||||
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
|
|
||||||
}
|
async _loadMarket(gridEl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/market/dashboards');
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
if (!items.length) { gridEl.innerHTML = this._emptyHtml('市场暂无仪表盘'); return; }
|
||||||
|
gridEl.innerHTML = items.map(d => {
|
||||||
|
const icon = d.icon || '📦';
|
||||||
|
return `<div class="glass-card theme-card" data-market-id="${d.id}">
|
||||||
|
<div class="theme-card-preview">${icon}</div>
|
||||||
|
<div class="theme-card-info">
|
||||||
|
<div class="theme-card-name">${d.name||'Unnamed'}</div>
|
||||||
|
<div class="theme-card-meta">
|
||||||
|
<span>${d.category||''}</span><span>v${d.version||'1.0'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-meta" style="margin-top:4px">
|
||||||
|
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次下载</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary btn-market-install" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">安装</button>
|
||||||
|
</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">市场加载失败</div>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
_isGameSupported(theme) {
|
_isGameSupported(theme) {
|
||||||
@@ -104,35 +125,142 @@ const PageDashboard = {
|
|||||||
const card = e.target.closest('.theme-card');
|
const card = e.target.closest('.theme-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const themeId = card.dataset.themeId;
|
const themeId = card.dataset.themeId;
|
||||||
this._openDashboard(themeId);
|
if (themeId) this._openDashboard(themeId);
|
||||||
|
|
||||||
|
const installBtn = e.target.closest('.btn-market-install');
|
||||||
|
if (installBtn) this._installFromMarket(installBtn.dataset.id, installBtn.dataset.file);
|
||||||
});
|
});
|
||||||
gridEl.addEventListener('contextmenu', (e) => {
|
gridEl.addEventListener('contextmenu', (e) => {
|
||||||
const card = e.target.closest('.theme-card');
|
const card = e.target.closest('.theme-card');
|
||||||
if (!card) return;
|
if (!card || !card.dataset.themeId) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const themeId = card.dataset.themeId;
|
this._exportDashboard(card.dataset.themeId);
|
||||||
this._exportDashboard(themeId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('dash-search')?.addEventListener('input', () => this._loadThemes());
|
||||||
|
document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes());
|
||||||
|
|
||||||
|
document.getElementById('btn-new-dashboard')?.addEventListener('click', () => this._createNew());
|
||||||
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
|
||||||
input.type = 'file';
|
|
||||||
input.accept = '.tsd';
|
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0]; if (!file) return;
|
||||||
if (!file) return;
|
const fd = new FormData(); fd.append('file', file);
|
||||||
const formData = new FormData();
|
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
|
||||||
formData.append('file', file);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
|
if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); }
|
||||||
else Toast.show('导入失败', 'error');
|
else Toast.show('导入失败', 'error');
|
||||||
} catch (err) { Toast.show('导入失败', 'error'); }
|
}; input.click();
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-market-upload')?.addEventListener('click', () => this._showMarketUpload());
|
||||||
|
document.getElementById('btn-market-login')?.addEventListener('click', () => this._showMarketLogin());
|
||||||
|
document.getElementById('btn-market-tab')?.addEventListener('click', () => {
|
||||||
|
document.getElementById('dash-category-list').querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
|
||||||
|
document.getElementById('btn-market-tab').classList.add('active');
|
||||||
|
this._marketMode = true;
|
||||||
|
document.getElementById('dash-search-box').style.display = 'none';
|
||||||
|
this._loadThemes();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updateMarketUI();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _installFromMarket(id, filename) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) { Toast.show('安装成功', 'success'); this._loadThemes(); }
|
||||||
|
else Toast.show('安装失败', 'error');
|
||||||
|
} catch (e) { Toast.show('安装失败', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_showMarketUpload() {
|
||||||
|
if (!this._marketToken) {
|
||||||
|
Toast.show('请先登录市场账户', 'info');
|
||||||
|
this._showMarketLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
fd.append('name', file.name.replace('.tsd',''));
|
||||||
|
fd.append('category', 'community');
|
||||||
|
fd.append('author', 'TurboSu User');
|
||||||
|
fd.append('token', this._marketToken);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/market/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.error ? '上传失败: ' + data.error : '上传成功', data.error ? 'error' : 'success');
|
||||||
|
} catch (e) { Toast.show('上传失败', 'error'); }
|
||||||
|
}; input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
_showMarketLogin() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px">
|
||||||
|
<h3>市场</h3>
|
||||||
|
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email"></div>
|
||||||
|
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="ml-logout" style="display:none;margin-right:auto">退出</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="ml-register">注册</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="ml-login">登录</button>
|
||||||
|
</div></div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
if (this._marketToken) {
|
||||||
|
overlay.querySelector('#ml-login').style.display = 'none';
|
||||||
|
overlay.querySelector('#ml-register').style.display = 'none';
|
||||||
|
overlay.querySelector('#ml-logout').style.display = '';
|
||||||
|
overlay.querySelector('#ml-logout').addEventListener('click', () => {
|
||||||
|
this._marketToken = null;
|
||||||
|
localStorage.removeItem('turbosu-market-token');
|
||||||
|
overlay.remove();
|
||||||
|
this._updateMarketUI();
|
||||||
|
Toast.show('已退出', 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
overlay.querySelector('#ml-login').addEventListener('click', async () => {
|
||||||
|
const email = document.getElementById('ml-email').value;
|
||||||
|
const pass = document.getElementById('ml-pass').value;
|
||||||
|
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password}) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.token) {
|
||||||
|
this._marketToken = data.token;
|
||||||
|
localStorage.setItem('turbosu-market-token', data.token);
|
||||||
|
Toast.show('登录成功', 'success');
|
||||||
|
overlay.remove();
|
||||||
|
this._updateMarketUI();
|
||||||
|
} else { Toast.show(data.message || '登录失败', 'error'); }
|
||||||
|
});
|
||||||
|
overlay.querySelector('#ml-register').addEventListener('click', async () => {
|
||||||
|
const email = document.getElementById('ml-email').value;
|
||||||
|
const pass = document.getElementById('ml-pass').value;
|
||||||
|
const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass}) });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件', data.error ? 'error' : 'success');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateMarketUI() {
|
||||||
|
const loginBtn = document.getElementById('btn-market-login');
|
||||||
|
const uploadBtn = document.getElementById('btn-market-upload');
|
||||||
|
if (!loginBtn || !uploadBtn) return;
|
||||||
|
if (this._marketToken) {
|
||||||
|
loginBtn.textContent = '已登录';
|
||||||
|
loginBtn.className = 'btn btn-sm btn-secondary';
|
||||||
|
uploadBtn.style.display = '';
|
||||||
|
} else {
|
||||||
|
loginBtn.textContent = '登录';
|
||||||
|
loginBtn.className = 'btn btn-sm btn-primary';
|
||||||
|
uploadBtn.style.display = 'none';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_openDashboard(themeId) {
|
_openDashboard(themeId) {
|
||||||
@@ -161,9 +289,11 @@ const PageDashboard = {
|
|||||||
const isAll = supported === 'all';
|
const isAll = supported === 'all';
|
||||||
const compatLabel = isAll ? '全' : '限';
|
const compatLabel = isAll ? '全' : '限';
|
||||||
const compatClass = isAll ? 'badge-info' : 'badge-warning';
|
const compatClass = isAll ? 'badge-info' : 'badge-warning';
|
||||||
|
const preview = theme.preview_ext ? `/dashboard/${theme.id}/preview.${theme.preview_ext}` : '';
|
||||||
|
const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : '';
|
||||||
return `
|
return `
|
||||||
<div class="glass-card theme-card" data-theme-id="${theme.id}">
|
<div class="glass-card theme-card" data-theme-id="${theme.id}">
|
||||||
<div class="theme-card-preview">${icon}</div>
|
<div class="theme-card-preview" style="${bgStyle}">${preview?'':icon}</div>
|
||||||
<div class="theme-card-info">
|
<div class="theme-card-info">
|
||||||
<div class="theme-card-name">${theme.name || 'Unnamed'}</div>
|
<div class="theme-card-name">${theme.name || 'Unnamed'}</div>
|
||||||
<div class="theme-card-meta">
|
<div class="theme-card-meta">
|
||||||
@@ -178,16 +308,40 @@ const PageDashboard = {
|
|||||||
</div>`;
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async _createNew() {
|
||||||
|
const name = prompt('仪表盘名称:', 'My Dashboard');
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>TurboSu - ' + name + '</title><style>*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;font-family:sans-serif}#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}#box{position:relative;overflow:hidden}#box.contain{max-width:100vw;max-height:100vh}#box.cover{min-width:100vw;min-height:100vh}#box.fill{width:100vw;height:100vh}</style></head><body><div id="root"><div id="box" class="contain"><div data-bind="speed_kmh" style="font-size:80px;font-weight:200">0</div></div></div><script>const M={aspect_ratio:"auto",render_mode:"contain"};(function(){const b=document.getElementById("box"),p=location.protocol==="https:"?"wss:":"ws:";let w,r;function a(){const rt=M.aspect_ratio==="auto"?null:(c=>{const p=c.split(":");return p.length===2?+p[0]/+p[1]:null})(M.aspect_ratio),md=M.render_mode||"contain";if(!rt){b.style.width="100vw";b.style.height="100vh";b.className="fill";return}const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;if(md==="cover"){cw=vw;ch=vw/rt;b.className="cover"}else{b.className="contain";cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}b.style.width=cw+"px";b.style.height=ch+"px"}function c(){w=new WebSocket(p+"//"+location.host+"/ws");w.onopen=()=>{if(r){clearTimeout(r);r=null}};w.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==="telemetry")document.querySelectorAll("[data-bind]").forEach(el=>{const v=m.data[el.getAttribute("data-bind")];if(v!==undefined)el.textContent=typeof v==="number"?v.toFixed(1):v})}catch(e){}};w.onclose=()=>{r=setTimeout(c,2000)}}a();window.addEventListener("resize",a);c()})();<\/script></body></html>';
|
||||||
|
const res = await fetch('/api/dashboards', {
|
||||||
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name, category: 'custom', author: 'Local', config: {icon: '📊', aspect_ratio: 'auto', render_mode: 'contain'}, index_html: html})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.id) { Toast.show('创建成功', 'success'); this._loadThemes(); }
|
||||||
|
else Toast.show('创建失败', 'error');
|
||||||
|
} catch (e) { Toast.show('创建失败', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
_template() {
|
_template() {
|
||||||
return `
|
return `
|
||||||
<div class="dashboard-layout">
|
<div class="dashboard-layout">
|
||||||
<div class="dashboard-sub-sidebar">
|
<div class="dashboard-sub-sidebar">
|
||||||
<div id="dash-category-list"></div>
|
<div id="dash-category-list"></div>
|
||||||
<div style="padding:8px 12px;margin-top:auto;">
|
<div style="padding:8px 12px;margin-top:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<button id="btn-new-dashboard" class="btn btn-sm btn-primary" style="width:100%;">+ 新建</button>
|
||||||
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">导入 .tsd</button>
|
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">导入 .tsd</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-main">
|
<div class="dashboard-main">
|
||||||
|
<div id="dash-search-box" style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
|
||||||
|
<input id="dash-search" type="text" placeholder="搜索仪表盘..." style="flex:1;max-width:300px">
|
||||||
|
<select id="dash-sort" style="width:120px">
|
||||||
|
<option value="name">名称</option>
|
||||||
|
<option value="category">分类</option>
|
||||||
|
<option value="author">作者</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="dash-theme-grid" class="theme-grid"></div>
|
<div id="dash-theme-grid" class="theme-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ const PageDebug = {
|
|||||||
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
|
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
|
||||||
this._autoScroll = e.target.checked;
|
this._autoScroll = e.target.checked;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('debug-show-log')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/logs?lines=100');
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('debug-raw');
|
||||||
|
if (el) el.innerHTML = data.lines.map(l =>
|
||||||
|
`<span style="color:${l.includes('ERROR')?'red':l.includes('WARNING')?'#f90':l.includes('INFO')?'#888':'#555'}">${l}</span>`
|
||||||
|
).join('\n');
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_onTelemetry(data) {
|
_onTelemetry(data) {
|
||||||
@@ -97,6 +108,7 @@ const PageDebug = {
|
|||||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
|
||||||
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
|
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
|
||||||
</label>
|
</label>
|
||||||
|
<button id="debug-show-log" class="btn btn-sm btn-secondary">📄 日志</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
|
||||||
<div style="overflow-y:auto;">
|
<div style="overflow-y:auto;">
|
||||||
|
|||||||
@@ -70,17 +70,17 @@ const PageHome = {
|
|||||||
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p>
|
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-grid animated" style="animation-delay:0.1s">
|
<div class="status-grid animated" style="animation-delay:0.1s">
|
||||||
|
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')}
|
||||||
|
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')}
|
||||||
|
${this._cardHtml('card-gear', '当前档位', '--', 'primary')}
|
||||||
|
</div>
|
||||||
|
<div class="status-grid animated" style="animation-delay:0.15s">
|
||||||
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')}
|
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')}
|
||||||
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')}
|
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')}
|
||||||
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
|
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
|
||||||
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
|
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
|
||||||
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
|
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
|
||||||
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
|
${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>`;
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
const PageMarket = {
|
||||||
|
_el: null,
|
||||||
|
_marketToken: null,
|
||||||
|
_tab: 'dashboards',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._el = document.getElementById('page-market');
|
||||||
|
this._marketToken = localStorage.getItem('turbosu-market-token');
|
||||||
|
},
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
this._bindEvents();
|
||||||
|
this._updateUI();
|
||||||
|
this._loadItems();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadItems() {
|
||||||
|
const grid = document.getElementById('market-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.style.display = 'grid';
|
||||||
|
grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
||||||
|
|
||||||
|
if (this._tab === 'mine') {
|
||||||
|
if (!this._marketToken) {
|
||||||
|
grid.innerHTML = this._emptyHtml('请先登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this._loadMine(grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isScene = this._tab === 'scenes';
|
||||||
|
const endpoint = isScene ? '/api/market/scenes' : '/api/market/dashboards';
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpoint);
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
let localIds = [];
|
||||||
|
try {
|
||||||
|
const lr = await fetch('/api/dashboards?category=all');
|
||||||
|
const ld = await lr.json();
|
||||||
|
if (Array.isArray(ld)) localIds = ld.map(d => d.id);
|
||||||
|
if (isScene) {
|
||||||
|
const sr = await fetch('/api/scenes');
|
||||||
|
const sl = await sr.json();
|
||||||
|
if (Array.isArray(sl)) localIds = localIds.concat(sl.map(s => s.id));
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('load local:', e); }
|
||||||
|
|
||||||
|
if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? '暂无场景' : '暂无仪表盘'); return; }
|
||||||
|
grid.innerHTML = items.map(d => {
|
||||||
|
const installed = d.manifest_id && localIds.includes(d.manifest_id);
|
||||||
|
return this._cardHtml(d, isScene, installed);
|
||||||
|
}).join('');
|
||||||
|
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\'btn-refresh-market\').click();return false" style="color:var(--accent)">重试</a></div>'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadMine(grid) {
|
||||||
|
grid.style.display = 'block';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken));
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.dashboards?.length && !data.scenes?.length) {
|
||||||
|
grid.innerHTML = this._emptyHtml('你还没有上传任何内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
if (data.dashboards?.length) {
|
||||||
|
html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">我的仪表盘</h3>' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join('');
|
||||||
|
}
|
||||||
|
if (data.scenes?.length) {
|
||||||
|
html += '<h3 style="margin:20px 0 10px;font-size:16px;color:var(--text-secondary)">我的场景</h3>' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join('');
|
||||||
|
}
|
||||||
|
grid.innerHTML = html;
|
||||||
|
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\"btn-refresh-market\").click();return false" style="color:var(--accent)">重试</a></div>'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_cardHtml(d, isScene, installed) {
|
||||||
|
const icon = d.preview ? '' : (isScene ? '🎬' : (d.icon || '📦'));
|
||||||
|
const previewUrl = d.preview ? `https://turbosu.yeij.top/api/files/${isScene?'scenes':'dashboards'}/${d.id}/${d.preview}` : '';
|
||||||
|
const bgStyle = previewUrl ? `background-image:url(${previewUrl});background-size:cover;background-position:center` : '';
|
||||||
|
const label = installed ? '已安装' : (isScene ? '安装场景' : '安装');
|
||||||
|
const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install';
|
||||||
|
const uninstallBtn = installed ? `<button class="btn btn-sm btn-danger btn-m-uninstall" data-name="${d.name||d.id}" style="margin-top:4px;width:100%">卸载</button>` : '';
|
||||||
|
return `<div class="glass-card theme-card">
|
||||||
|
<div class="theme-card-preview" style="${bgStyle}">${icon}</div>
|
||||||
|
<div class="theme-card-info">
|
||||||
|
<div class="theme-card-name">${d.name||'Unnamed'}</div>
|
||||||
|
<div class="theme-card-meta">
|
||||||
|
<span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-meta" style="margin-top:4px">
|
||||||
|
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary ${btnClass}" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%" ${installed?'disabled':''}>${label}</button>
|
||||||
|
${uninstallBtn}
|
||||||
|
</div></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_myCardHtml(d, collection) {
|
||||||
|
const pending = d.dashb_pending ? '<span class="badge" style="background:rgba(255,159,10,.15);color:#FF9F0A">待审核</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">已通过</span>';
|
||||||
|
const created = d.created ? new Date(d.created).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-';
|
||||||
|
const updated = d.updated ? new Date(d.updated).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : created;
|
||||||
|
return `<div class="glass-card" style="padding:14px 16px;margin-bottom:8px">
|
||||||
|
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${d.name||'Unnamed'} <span style="color:var(--text-tertiary);font-weight:400;font-size:12px">v${d.version||'1.0'}</span></div>
|
||||||
|
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:4px">
|
||||||
|
${d.author||''} · ${d.downloads||0} 次下载 · ${pending} <br>
|
||||||
|
<span style="font-size:11px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<button class="btn btn-sm btn-secondary btn-my-update" data-id="${d.id}" data-coll="${collection}" data-file="${d.file||''}" data-preview="${d.preview||''}" data-type="${collection==='scenes'?'.tss':'.tsd'}">更新</button>
|
||||||
|
<button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">删除</button>
|
||||||
|
</div></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('btn-refresh-market')?.addEventListener('click', () => this._loadItems());
|
||||||
|
document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload());
|
||||||
|
document.getElementById('market-grid')?.addEventListener('click', async (e) => {
|
||||||
|
const btnInstall = e.target.closest('.btn-m-install');
|
||||||
|
const btnScene = e.target.closest('.btn-m-install-scene');
|
||||||
|
const btnUpdate = e.target.closest('.btn-my-update');
|
||||||
|
const btnDelete = e.target.closest('.btn-my-delete');
|
||||||
|
|
||||||
|
if (e.target.closest('.btn-m-uninstall')) {
|
||||||
|
const ubtn = e.target.closest('.btn-m-uninstall');
|
||||||
|
const uname = ubtn.dataset.name||'';
|
||||||
|
if (!confirm('确定卸载 "' + uname + '"?')) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/dashboards?category=all');
|
||||||
|
const list = (await r.json()) || [];
|
||||||
|
const dash = list.find(d => d.id === uname || d.name === uname);
|
||||||
|
if (dash) {
|
||||||
|
await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' });
|
||||||
|
Toast.show('已卸载', 'success');
|
||||||
|
this._loadItems();
|
||||||
|
} else { Toast.show('未找到本地安装', 'error'); }
|
||||||
|
} catch (e) { Toast.show('卸载失败', 'error'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnUpdate) {
|
||||||
|
this._showUpdateModal(btnUpdate.dataset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btnDelete) {
|
||||||
|
if (!confirm('确定删除?')) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) });
|
||||||
|
Toast.show('已删除', 'info');
|
||||||
|
this._loadItems();
|
||||||
|
} catch (e) { Toast.show('删除失败', 'error'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = (btnInstall || btnScene)?.dataset.id;
|
||||||
|
const file = (btnInstall || btnScene)?.dataset.file;
|
||||||
|
if (!id || !file) { Toast.show('安装失败:数据不完整', 'error'); return; }
|
||||||
|
const isScene = !!btnScene;
|
||||||
|
const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`
|
||||||
|
: `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
Toast.show('安装成功!请在仪表盘页面查看', 'success', 4000);
|
||||||
|
this._loadItems();
|
||||||
|
} else {
|
||||||
|
Toast.show('安装失败: ' + (data.error||'请求异常'), 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { Toast.show('安装失败:网络错误', 'error'); }
|
||||||
|
});
|
||||||
|
document.getElementById('tab-dashboards')?.addEventListener('click', () => {
|
||||||
|
this._tab = 'dashboards';
|
||||||
|
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
this._loadItems();
|
||||||
|
});
|
||||||
|
document.getElementById('tab-scenes')?.addEventListener('click', () => {
|
||||||
|
this._tab = 'scenes';
|
||||||
|
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
this._loadItems();
|
||||||
|
});
|
||||||
|
document.getElementById('tab-mine')?.addEventListener('click', () => {
|
||||||
|
this._tab = 'mine';
|
||||||
|
document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px';
|
||||||
|
this._loadItems();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateUI() {
|
||||||
|
const loginBtn = document.getElementById('btn-m-login');
|
||||||
|
const uploadBtn = document.getElementById('btn-m-upload');
|
||||||
|
const statusEl = document.getElementById('m-status');
|
||||||
|
if (!loginBtn || !uploadBtn) return;
|
||||||
|
if (this._marketToken) {
|
||||||
|
loginBtn.textContent = '已登录';
|
||||||
|
loginBtn.className = 'btn btn-sm btn-secondary';
|
||||||
|
uploadBtn.style.display = '';
|
||||||
|
if (statusEl) statusEl.textContent = ' 已登录';
|
||||||
|
} else {
|
||||||
|
loginBtn.textContent = '登录';
|
||||||
|
loginBtn.className = 'btn btn-sm btn-primary';
|
||||||
|
uploadBtn.style.display = 'none';
|
||||||
|
if (statusEl) statusEl.textContent = ' 未登录';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_upload() {
|
||||||
|
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; }
|
||||||
|
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">发布作品</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">选择要发布的仪表盘或场景</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle"><span class="switch-track"></span> 上传预览图</label></div><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择文件 (.tsd / .tss)</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">— 或从本地选择 —</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">加载中...</div></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
|
let previewFile = null;
|
||||||
|
const previewDrop = document.createElement('div');
|
||||||
|
previewDrop.id = 'pub-preview-drop';
|
||||||
|
previewDrop.style.cssText = 'display:none;border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin-top:8px;transition:all .2s';
|
||||||
|
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)';
|
||||||
|
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn'));
|
||||||
|
|
||||||
|
function pickPreview() {
|
||||||
|
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
|
||||||
|
inp.onchange = function() {
|
||||||
|
const f = this.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
||||||
|
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
||||||
|
previewFile = f;
|
||||||
|
previewDrop.textContent = '✓ ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
||||||
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
|
};
|
||||||
|
document.body.appendChild(inp); inp.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
['dragenter','dragover'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--accent)'; }));
|
||||||
|
['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; }));
|
||||||
|
previewDrop.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
||||||
|
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
||||||
|
previewFile = f;
|
||||||
|
previewDrop.textContent = '✓ ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
||||||
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
|
});
|
||||||
|
previewDrop.addEventListener('click', pickPreview);
|
||||||
|
|
||||||
|
overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
previewDrop.style.display = '';
|
||||||
|
} else {
|
||||||
|
previewFile = null;
|
||||||
|
previewDrop.style.display = 'none';
|
||||||
|
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)';
|
||||||
|
previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#pub-file-btn').addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd,.tss';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const isTss = file.name.endsWith('.tss');
|
||||||
|
const fd = new FormData(); fd.append('file', file); fd.append('token', this._marketToken);
|
||||||
|
if (previewFile) fd.append('preview', previewFile);
|
||||||
|
const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.error ? '失败: '+data.error : '发布成功', data.error ? 'error' : 'success');
|
||||||
|
if (!data.error) { overlay.remove(); this._loadItems(); }
|
||||||
|
}; input.click();
|
||||||
|
});
|
||||||
|
this._loadLocalItems(overlay);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadLocalItems(overlay) {
|
||||||
|
const list = overlay.querySelector('#pub-local-list');
|
||||||
|
try {
|
||||||
|
const dashRes = await fetch('/api/dashboards?category=all');
|
||||||
|
const dashes = (await dashRes.json()) || [];
|
||||||
|
const sceneRes = await fetch('/api/scenes');
|
||||||
|
const scenes = (await sceneRes.json()) || [];
|
||||||
|
const upD = dashes.filter(d => !d.is_builtin && !(d.id||'').match(/^[a-f0-9]{6}__/));
|
||||||
|
const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.id||'').match(/^[a-f0-9]{6}__/)) : [];
|
||||||
|
let h = '';
|
||||||
|
if (upD.length) {
|
||||||
|
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地仪表盘 ('+upD.length+')</div>';
|
||||||
|
h += '<div class="pub-group">' + upD.map(d => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">'+(d.icon||'📊')+' '+d.name+'</span></div><button class="btn btn-sm btn-primary pub-dash-btn" data-id="'+d.id+'">发布</button></div>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
if (upS.length) {
|
||||||
|
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin:10px 0 4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地场景 ('+upS.length+')</div>';
|
||||||
|
h += '<div class="pub-group">' + upS.map(s => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">🎬 '+s.name+'</span></div><button class="btn btn-sm btn-primary pub-scene-btn" data-id="'+s.id+'">发布</button></div>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
list.innerHTML = h || '<p style="color:var(--text-tertiary);font-size:12px;padding:10px">没有可发布的内容</p>';
|
||||||
|
list.querySelectorAll('.pub-dash-btn').forEach(btn => btn.addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'dashboard',token:this._marketToken})});
|
||||||
|
const d = await r.json();
|
||||||
|
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
|
||||||
|
if(!d.error){overlay.remove();this._loadItems();}
|
||||||
|
}));
|
||||||
|
list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'scene',token:this._marketToken})});
|
||||||
|
const d = await r.json();
|
||||||
|
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
|
||||||
|
if(!d.error){overlay.remove();this._loadItems();}
|
||||||
|
}));
|
||||||
|
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">加载失败</p>'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
_showLogin() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
const isLogin = !this._marketToken;
|
||||||
|
overlay.innerHTML = `<div class="modal" style="min-width:380px;max-width:420px;padding:24px">
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border-subtle)">
|
||||||
|
<button id="ml-tab-login" class="btn btn-sm ${isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${isLogin?'var(--accent)':'transparent'};color:${isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">登录</button>
|
||||||
|
<button id="ml-tab-register" class="btn btn-sm ${!isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${!isLogin?'var(--accent)':'transparent'};color:${!isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">注册</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email" placeholder="your@email.com"></div>
|
||||||
|
<div class="form-group" id="ml-name-group" style="display:${!isLogin?'':'none'}"><label>用户名</label><input id="ml-name" type="text" placeholder="必填"></div>
|
||||||
|
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-sm" id="ml-logout" style="display:${this._marketToken?'':'none'};margin-right:auto;background:var(--bg2);color:var(--text2)">退出</button>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?'登录':'注册'}</button>
|
||||||
|
</div></div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
if (this._marketToken) {
|
||||||
|
overlay.querySelector('#ml-logout').addEventListener('click', () => {
|
||||||
|
this._marketToken = null;
|
||||||
|
localStorage.removeItem('turbosu-market-token');
|
||||||
|
overlay.remove();
|
||||||
|
this._updateUI();
|
||||||
|
Toast.show('已退出', 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = isLogin ? 'login' : 'register';
|
||||||
|
const tabLogin = overlay.querySelector('#ml-tab-login');
|
||||||
|
const tabRegister = overlay.querySelector('#ml-tab-register');
|
||||||
|
const nameGroup = overlay.querySelector('#ml-name-group');
|
||||||
|
const submitBtn = overlay.querySelector('#ml-submit');
|
||||||
|
|
||||||
|
function setTab(t) {
|
||||||
|
mode = t;
|
||||||
|
const isL = t === 'login';
|
||||||
|
tabLogin.style.cssText = `border-radius:0;border-bottom:2px solid ${isL?'var(--accent)':'transparent'};color:${isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
|
||||||
|
tabRegister.style.cssText = `border-radius:0;border-bottom:2px solid ${!isL?'var(--accent)':'transparent'};color:${!isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
|
||||||
|
nameGroup.style.display = isL ? 'none' : '';
|
||||||
|
submitBtn.textContent = isL ? '登录' : '注册';
|
||||||
|
}
|
||||||
|
|
||||||
|
tabLogin.addEventListener('click', () => setTab('login'));
|
||||||
|
tabRegister.addEventListener('click', () => setTab('register'));
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
|
const doAuth = async () => {
|
||||||
|
const email = document.getElementById('ml-email').value;
|
||||||
|
const pass = document.getElementById('ml-pass').value;
|
||||||
|
if (!email || !pass) { Toast.show('请输入邮箱和密码', 'error'); return; }
|
||||||
|
if (mode === 'login') {
|
||||||
|
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.token) {
|
||||||
|
this._marketToken = data.token;
|
||||||
|
localStorage.setItem('turbosu-market-token', data.token);
|
||||||
|
Toast.show('登录成功', 'success');
|
||||||
|
overlay.remove();
|
||||||
|
this._updateUI();
|
||||||
|
} else { Toast.show(data.message || '登录失败', 'error'); }
|
||||||
|
} else {
|
||||||
|
const name = document.getElementById('ml-name')?.value || '';
|
||||||
|
const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass, name}) });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件(点击关闭)', data.error ? 'error' : 'success', data.error ? 3000 : 0);
|
||||||
|
if (!data.error) overlay.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.querySelector('#ml-submit').addEventListener('click', doAuth);
|
||||||
|
overlay.querySelector('#ml-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') doAuth(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
_emptyHtml(msg) {
|
||||||
|
return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||'暂无'}</h4></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
_showUpdateModal(ds) {
|
||||||
|
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; }
|
||||||
|
const isScene = ds.coll === 'scenes';
|
||||||
|
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">更新 '+(isScene?'场景':'仪表盘')+'</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">重新上传包文件或更换预览图</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle" checked><span class="switch-track"></span> 更换预览图</label></div><button id="pub-update-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择新文件 ('+(isScene?'.tss':'.tsd')+')</button><div style="font-size:13px;color:var(--text-tertiary);margin-top:10px;text-align:center">注:仅更新预览图可不选文件</div></div>';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
|
let updateFile = null, previewFile = null;
|
||||||
|
const previewDrop = document.createElement('div');
|
||||||
|
previewDrop.style.cssText = 'border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin:8px 0;transition:all .2s';
|
||||||
|
previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)';
|
||||||
|
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn'));
|
||||||
|
|
||||||
|
function pickPreview() {
|
||||||
|
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
|
||||||
|
inp.onchange = function() {
|
||||||
|
const f = this.files[0]; if (!f) return;
|
||||||
|
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
||||||
|
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
||||||
|
previewFile = f; previewDrop.textContent = '✓ '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
|
||||||
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
|
};
|
||||||
|
document.body.appendChild(inp); inp.click();
|
||||||
|
}
|
||||||
|
['dragenter','dragover'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--accent)'; }));
|
||||||
|
['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; }));
|
||||||
|
previewDrop.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return;
|
||||||
|
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
||||||
|
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
||||||
|
previewFile = f; previewDrop.textContent = '✓ '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
|
||||||
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
|
});
|
||||||
|
previewDrop.addEventListener('click', pickPreview);
|
||||||
|
|
||||||
|
overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() {
|
||||||
|
previewDrop.style.display = this.checked ? '' : 'none';
|
||||||
|
if (!this.checked) { previewFile = null; previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)'; previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#pub-update-btn').addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd';
|
||||||
|
input.onchange = function(e) { updateFile = e.target.files[0]; if (updateFile) { const btn = overlay.querySelector('#pub-update-btn'); btn.textContent = '✓ '+updateFile.name; btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; } };
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('#pub-update-btn').insertAdjacentHTML('afterend', '<button id="pub-do-update" class="btn btn-primary btn-sm" style="width:100%;margin-top:10px">确认更新</button>');
|
||||||
|
overlay.querySelector('#pub-do-update').addEventListener('click', async () => {
|
||||||
|
if (!updateFile && !previewFile) { Toast.show('请选择文件或预览图', 'error'); return; }
|
||||||
|
const fd = new FormData(); fd.append('token', this._marketToken);
|
||||||
|
if (updateFile) fd.append('file', updateFile);
|
||||||
|
if (previewFile) fd.append('preview', previewFile);
|
||||||
|
// If no new preview given, fetch old one from record
|
||||||
|
if (!previewFile && ds.preview) {
|
||||||
|
try {
|
||||||
|
const prevRes = await fetch('https://turbosu.yeij.top/api/files/'+ds.coll+'/'+ds.id+'/'+ds.preview);
|
||||||
|
if (prevRes.ok) {
|
||||||
|
const blob = await prevRes.blob();
|
||||||
|
fd.append('preview', blob, ds.preview);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
const url = isScene ? '/api/market/scenes/upload' : '/api/market/upload';
|
||||||
|
const res = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.error && data.id) {
|
||||||
|
// Delete old record
|
||||||
|
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: ds.id, collection: ds.coll, token: this._marketToken}) });
|
||||||
|
}
|
||||||
|
Toast.show(data.error ? '更新失败: '+data.error : '更新成功', data.error ? 'error' : 'success');
|
||||||
|
if (!data.error) { overlay.remove(); this._loadItems(); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_template() {
|
||||||
|
return `<div class="animated">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
|
||||||
|
<div>
|
||||||
|
<h2 style="font-size:24px;font-weight:700">仪表盘市场</h2>
|
||||||
|
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">浏览并安装社区分享的仪表盘和场景 <span id="m-status" style="color:var(--text-tertiary)"> 未登录</span></p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">发布你的作品</button>
|
||||||
|
<button id="btn-m-login" class="btn btn-sm btn-primary">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle);align-items:flex-end">
|
||||||
|
<button id="tab-dashboards" class="btn btn-sm active" style="border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px">仪表盘</button>
|
||||||
|
<button id="tab-scenes" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">场景</button>
|
||||||
|
<button id="tab-mine" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">我的</button>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button id="btn-refresh-market" class="icon-btn" style="width:auto;padding:0 8px;margin-bottom:4px;font-size:12px" title="刷新">🔄</button>
|
||||||
|
</div>
|
||||||
|
<div id="market-grid" class="theme-grid"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageMarket = PageMarket;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
const PageRecord = {
|
||||||
|
_el: null,
|
||||||
|
|
||||||
|
init() { this._el = document.getElementById('page-record'); },
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this._el.innerHTML = this._template();
|
||||||
|
this._bindEvents();
|
||||||
|
this._loadRecordings();
|
||||||
|
},
|
||||||
|
|
||||||
|
async _loadRecordings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recordings');
|
||||||
|
const list = await res.json();
|
||||||
|
const el = document.getElementById('rec-file-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:13px;padding:20px">暂无录制文件</p>'; return; }
|
||||||
|
el.innerHTML = list.map(f => {
|
||||||
|
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
||||||
|
return '<div class="glass-card" style="padding:10px 14px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap"><span style="font-size:12px;flex:1;min-width:150px">' + f.name + '</span><span style="font-size:11px;color:var(--text-tertiary)">' + size + '</span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button><a class="btn btn-sm btn-secondary" href="/api/recording/' + f.name + '/export" download>导出</a><button class="btn btn-sm btn-danger btn-del-rec" data-file="' + f.name + '">删除</button></div>';
|
||||||
|
}).join('');
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('btn-rec-start')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/record/start', { method: 'POST' });
|
||||||
|
this._updateRecUI(true);
|
||||||
|
});
|
||||||
|
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/record/stop', { method: 'POST' });
|
||||||
|
this._updateRecUI(false);
|
||||||
|
this._loadRecordings();
|
||||||
|
});
|
||||||
|
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
|
||||||
|
await fetch('/api/record/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: e.target.checked }) });
|
||||||
|
});
|
||||||
|
document.getElementById('btn-import-rec')?.addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsr';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
await fetch('/api/recording/import', { method: 'POST', body: fd });
|
||||||
|
Toast.show('导入成功', 'success'); this._loadRecordings();
|
||||||
|
}; input.click();
|
||||||
|
});
|
||||||
|
document.getElementById('rec-file-list')?.addEventListener('click', async (e) => {
|
||||||
|
const playBtn = e.target.closest('.btn-play-rec');
|
||||||
|
const delBtn = e.target.closest('.btn-del-rec');
|
||||||
|
if (playBtn) this._playRecording(playBtn.dataset.file);
|
||||||
|
if (delBtn && confirm('删除?')) {
|
||||||
|
await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' });
|
||||||
|
this._loadRecordings();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('btn-playback-stop')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/playback/stop', { method: 'POST' });
|
||||||
|
document.getElementById('playback-bar').style.display = 'none';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_updateRecUI(recording) {
|
||||||
|
document.getElementById('btn-rec-start').style.display = recording ? 'none' : '';
|
||||||
|
document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none';
|
||||||
|
document.getElementById('rec-status').textContent = recording ? '录制中...' : '就绪';
|
||||||
|
},
|
||||||
|
|
||||||
|
async _playRecording(filename) {
|
||||||
|
document.getElementById('playback-file').textContent = filename;
|
||||||
|
document.getElementById('playback-bar').style.display = 'flex';
|
||||||
|
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
||||||
|
Toast.show('回放已开始,打开仪表盘查看数据', 'info');
|
||||||
|
},
|
||||||
|
|
||||||
|
_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:20px">录制原始 UDP 流到 .tsr 文件,回放调试仪表盘</p>' +
|
||||||
|
|
||||||
|
'<div class="glass-card" style="padding:20px;margin-bottom:20px"><h3 style="font-size:16px;margin-bottom:12px">录制控制</h3><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><button id="btn-rec-start" class="btn btn-primary">开始录制</button><button id="btn-rec-stop" class="btn btn-danger" style="display:none">停止录制</button><span id="rec-status" style="font-size:13px;color:var(--text-secondary)">就绪</span><span style="flex:1"></span><label class="switch-label"><input type="checkbox" id="rec-auto-mode"><span class="switch-track"></span> 自动检测</label></div></div>' +
|
||||||
|
|
||||||
|
'<div id="playback-bar" class="glass-card" style="padding:12px 20px;margin-bottom:20px;display:none;align-items:center;gap:12px;background:var(--accent-container)"><span style="font-size:13px">正在回放: <strong id="playback-file">-</strong></span><span style="flex:1"></span><span style="font-size:12px;color:var(--text-tertiary)">打开仪表盘页面即可看到数据</span><button id="btn-playback-stop" class="btn btn-sm btn-danger">停止</button></div>' +
|
||||||
|
|
||||||
|
'<div class="glass-card" style="padding:20px"><h3 style="font-size:16px;margin-bottom:12px">录制文件</h3><div style="display:flex;gap:8px;margin-bottom:12px"><button id="btn-import-rec" class="btn btn-sm btn-secondary">导入 .tsr</button></div><div id="rec-file-list"></div></div>' +
|
||||||
|
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.PageRecord = PageRecord;
|
||||||
+194
-146
@@ -3,7 +3,14 @@ const PageScene = {
|
|||||||
_currentGameId: null,
|
_currentGameId: null,
|
||||||
_currentGameName: '',
|
_currentGameName: '',
|
||||||
_editorSceneId: null,
|
_editorSceneId: null,
|
||||||
_editorData: null,
|
_editorCanvasIdx: 0,
|
||||||
|
_placements: [],
|
||||||
|
_dashboards: [],
|
||||||
|
_dragItem: null,
|
||||||
|
_dragOffsetX: 0,
|
||||||
|
_dragOffsetY: 0,
|
||||||
|
_resizeItem: null,
|
||||||
|
_resizeDir: '',
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._el = document.getElementById('page-scene');
|
this._el = document.getElementById('page-scene');
|
||||||
@@ -12,208 +19,249 @@ const PageScene = {
|
|||||||
async render() {
|
async render() {
|
||||||
const cfg = await API.getConfig();
|
const cfg = await API.getConfig();
|
||||||
this._currentGameId = cfg.selected_game_id;
|
this._currentGameId = cfg.selected_game_id;
|
||||||
|
|
||||||
if (this._currentGameId) {
|
if (this._currentGameId) {
|
||||||
const games = await API.getGames();
|
const games = await API.getGames();
|
||||||
const game = games.find(g => g.id === this._currentGameId);
|
const game = games.find(g => g.id === this._currentGameId);
|
||||||
this._currentGameName = game ? game.name : this._currentGameId;
|
this._currentGameName = game ? game.name : this._currentGameId;
|
||||||
|
this._dashboards = await API.getDashboards('all');
|
||||||
}
|
}
|
||||||
|
|
||||||
this._el.innerHTML = this._template();
|
this._el.innerHTML = this._template();
|
||||||
await this._loadScenes();
|
this._showSceneList();
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
},
|
},
|
||||||
|
|
||||||
async _loadScenes() {
|
async _showSceneList() {
|
||||||
|
document.getElementById('scene-list-container').style.display = 'block';
|
||||||
|
document.getElementById('scene-editor-container').style.display = 'none';
|
||||||
const grid = document.getElementById('scene-grid');
|
const grid = document.getElementById('scene-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const scenes = await API.getScenes(this._currentGameId);
|
const scenes = await API.getScenes(this._currentGameId);
|
||||||
if (!scenes || scenes.length === 0) {
|
if (!scenes || scenes.length === 0) {
|
||||||
grid.innerHTML = `
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>暂无场景</h4><p>点击"新建场景"创建你的场景</p></div>`;
|
||||||
<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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
||||||
} catch (e) {
|
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
||||||
console.error('Failed to load scenes:', e);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_bindEvents() {
|
async _openEditor(sceneId) {
|
||||||
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor());
|
this._editorSceneId = sceneId;
|
||||||
document.getElementById('btn-import-scene')?.addEventListener('click', () => {
|
this._editorCanvasIdx = 0;
|
||||||
const input = document.createElement('input');
|
this._placements = [];
|
||||||
input.type = 'file';
|
|
||||||
input.accept = '.tss';
|
|
||||||
input.onchange = async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/scenes/import', { method: 'POST', body: formData });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.ok) { Toast.show('场景导入成功', 'success'); this._loadScenes(); }
|
|
||||||
else Toast.show('导入失败', 'error');
|
|
||||||
} catch (err) { Toast.show('导入失败', 'error'); }
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
} else if (action === 'export') {
|
|
||||||
e.stopPropagation();
|
|
||||||
this._exportScene(sceneId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async _showEditor(sceneId) {
|
|
||||||
let scene = null;
|
let scene = null;
|
||||||
if (sceneId) {
|
if (sceneId) {
|
||||||
scene = await API.getScene(sceneId);
|
scene = await API.getScene(sceneId);
|
||||||
if (!scene) return;
|
if (scene && scene.canvases && scene.canvases[0]) {
|
||||||
|
this._placements = scene.canvases[0].placements || [];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const listC = document.getElementById('scene-list-container');
|
||||||
|
const editorC = document.getElementById('scene-editor-container');
|
||||||
|
listC.style.display = 'none';
|
||||||
|
editorC.style.display = 'flex';
|
||||||
|
this._renderCanvasEditor(scene);
|
||||||
|
},
|
||||||
|
|
||||||
this._editorSceneId = sceneId;
|
_renderCanvasEditor(scene) {
|
||||||
const modal = document.createElement('div');
|
const nameInput = document.getElementById('edit-scene-name');
|
||||||
modal.className = 'modal-overlay';
|
if (nameInput && scene) nameInput.value = scene.name || '';
|
||||||
modal.innerHTML = this._editorTemplate(scene);
|
this._renderDashboardList();
|
||||||
document.body.appendChild(modal);
|
this._renderCanvas();
|
||||||
|
},
|
||||||
|
|
||||||
modal.querySelector('.modal-overlay, .modal-actions .btn-secondary')
|
_renderDashboardList() {
|
||||||
?.addEventListener('click', (e) => {
|
const list = document.getElementById('editor-dash-list');
|
||||||
if (e.target === modal || e.target.matches('.btn-secondary')) {
|
if (!list) return;
|
||||||
modal.remove();
|
const filtered = this._dashboards.filter(d => d.supported_games === 'all' || (d.supported_games||'').split(',').includes(this._currentGameId));
|
||||||
|
list.innerHTML = filtered.map(d =>
|
||||||
|
`<div class="glass-card editor-dash-item" draggable="true" data-dash-id="${d.id}" style="padding:10px;margin-bottom:6px;cursor:grab;font-size:13px;display:flex;align-items:center;gap:8px">
|
||||||
|
<span>${d.icon||'📊'}</span>
|
||||||
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
|
||||||
|
</div>`
|
||||||
|
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">无兼容仪表盘</p>';
|
||||||
|
|
||||||
|
list.querySelectorAll('.editor-dash-item').forEach(el => {
|
||||||
|
el.addEventListener('dragstart', (e) => {
|
||||||
|
this._dragItem = { dashId: el.dataset.dashId, isNew: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderCanvas() {
|
||||||
|
const canvas = document.getElementById('editor-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const cw = 1920, ch = 1080;
|
||||||
|
const scale = Math.min(canvas.parentElement.clientWidth / cw, 1);
|
||||||
|
canvas.style.width = (cw * scale) + 'px';
|
||||||
|
canvas.style.height = (ch * scale) + 'px';
|
||||||
|
|
||||||
|
canvas.innerHTML = this._placements.map((p, idx) => {
|
||||||
|
const dash = this._dashboards.find(d => d.id === p.dashboard_id);
|
||||||
|
const name = dash ? dash.name : p.dashboard_id;
|
||||||
|
const icon = dash ? (dash.icon || '📊') : '❓';
|
||||||
|
return `<div class="editor-placement" data-idx="${idx}" style="left:${p.x*scale}px;top:${p.y*scale}px;width:${p.width*scale}px;height:${p.height*scale}px;z-index:${p.z_index||0}">
|
||||||
|
<div class="ep-icon">${icon}</div>
|
||||||
|
<div class="ep-name">${name}</div>
|
||||||
|
<div class="ep-actions">
|
||||||
|
<button class="btn btn-sm btn-danger" data-action="remove-place" data-idx="${idx}">×</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
canvas.querySelectorAll('.editor-placement').forEach(el => {
|
||||||
|
const idx = parseInt(el.dataset.idx);
|
||||||
|
el.addEventListener('mousedown', (e) => this._startDragPlace(e, idx, el));
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('[data-action="remove-place"]')) {
|
||||||
|
this._placements.splice(idx, 1);
|
||||||
|
this._renderCanvas();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modal.querySelector('.btn-primary')?.addEventListener('click', async () => {
|
canvas.addEventListener('dragover', (e) => e.preventDefault());
|
||||||
const name = modal.querySelector('#edit-scene-name').value || 'New Scene';
|
canvas.addEventListener('drop', (e) => {
|
||||||
const desc = modal.querySelector('#edit-scene-desc').value || '';
|
e.preventDefault();
|
||||||
const gameId = this._currentGameId || '';
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / scale;
|
||||||
|
const y = (e.clientY - rect.top) / scale;
|
||||||
|
if (this._dragItem && this._dragItem.isNew) {
|
||||||
|
this._placements.push({
|
||||||
|
dashboard_id: this._dragItem.dashId,
|
||||||
|
x, y, width: 300, height: 200, z_index: this._placements.length
|
||||||
|
});
|
||||||
|
this._renderCanvas();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
const data = { name, description: desc, game_id: gameId };
|
_startDragPlace(e, idx, el) {
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
const canvasEl = document.getElementById('editor-canvas');
|
||||||
|
const rect = canvasEl.getBoundingClientRect();
|
||||||
|
const scale = parseFloat(canvasEl.style.width) / 1920;
|
||||||
|
const startX = e.clientX, startY = e.clientY;
|
||||||
|
const origP = { ...this._placements[idx] };
|
||||||
|
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const dx = (ev.clientX - startX) / scale;
|
||||||
|
const dy = (ev.clientY - startY) / scale;
|
||||||
|
this._placements[idx].x = origP.x + dx;
|
||||||
|
this._placements[idx].y = origP.y + dy;
|
||||||
|
el.style.left = (this._placements[idx].x * scale) + 'px';
|
||||||
|
el.style.top = (this._placements[idx].y * scale) + 'px';
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _saveScene() {
|
||||||
|
const name = document.getElementById('edit-scene-name')?.value || 'New Scene';
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
game_id: this._currentGameId || '',
|
||||||
|
canvases: [{ width: 1920, height: 1080, label: '16:9', placements: this._placements }]
|
||||||
|
};
|
||||||
|
try {
|
||||||
if (this._editorSceneId) {
|
if (this._editorSceneId) {
|
||||||
await API.updateScene(this._editorSceneId, data);
|
await API.updateScene(this._editorSceneId, data);
|
||||||
} else {
|
} else {
|
||||||
await API.createScene(data);
|
const created = await API.createScene(data);
|
||||||
|
this._editorSceneId = created.id;
|
||||||
}
|
}
|
||||||
modal.remove();
|
|
||||||
Toast.show('场景保存成功', 'success');
|
Toast.show('场景保存成功', 'success');
|
||||||
this.render();
|
this._showSceneList();
|
||||||
|
} catch (e) { Toast.show('保存失败: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._openEditor(null));
|
||||||
|
document.getElementById('btn-import-scene')?.addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tss';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
const res = await fetch('/api/scenes/import', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) { Toast.show('场景导入成功', 'success'); this._showSceneList(); }
|
||||||
|
else Toast.show('导入失败', 'error');
|
||||||
|
}; input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-back-to-list')?.addEventListener('click', () => this._showSceneList());
|
||||||
|
document.getElementById('btn-save-scene')?.addEventListener('click', () => this._saveScene());
|
||||||
|
|
||||||
|
document.getElementById('scene-grid')?.addEventListener('click', (e) => {
|
||||||
|
const card = e.target.closest('[data-scene-id]');
|
||||||
|
if (!card) return;
|
||||||
|
const action = card.dataset.action, sceneId = card.dataset.sceneId;
|
||||||
|
if (action === 'render') this._renderScene(sceneId);
|
||||||
|
else if (action === 'edit') this._openEditor(sceneId);
|
||||||
|
else if (action === 'delete') { if (confirm('确定删除?')) { API.deleteScene(sceneId); this._showSceneList(); } }
|
||||||
|
else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async _renderScene(sceneId) {
|
_renderScene(sceneId) {
|
||||||
const url = `${location.origin}/scene/${sceneId}`;
|
const url = `${location.origin}/scene/${sceneId}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {});
|
||||||
Toast.show('场景链接已复制,可在局域网设备访问', 'success');
|
|
||||||
}).catch(() => {});
|
|
||||||
},
|
|
||||||
|
|
||||||
async _deleteScene(sceneId) {
|
|
||||||
if (!confirm('确定删除此场景?')) return;
|
|
||||||
await API.deleteScene(sceneId);
|
|
||||||
Toast.show('场景已删除', 'info');
|
|
||||||
this._loadScenes();
|
|
||||||
},
|
|
||||||
|
|
||||||
_exportScene(sceneId) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = `/api/scenes/${sceneId}/export`;
|
|
||||||
a.download = `${sceneId}.tss`;
|
|
||||||
a.click();
|
|
||||||
Toast.show('正在下载 .tss 文件...', 'info');
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_sceneCardHtml(scene) {
|
_sceneCardHtml(scene) {
|
||||||
const canvasCount = (scene.canvases || []).length;
|
const canvasCount = (scene.canvases || []).length;
|
||||||
return `
|
const preview = scene.preview_ext ? `/scene/${scene.id}/preview.${scene.preview_ext}` : '';
|
||||||
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
|
return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
|
||||||
<div class="scene-card-name">${scene.name}</div>
|
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
|
||||||
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div>
|
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
|
||||||
<div class="scene-card-meta">
|
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || '未关联游戏'}</div>
|
||||||
<span>${canvasCount} 个画布</span>
|
<div class="scene-card-meta"><span>${canvasCount} 画布</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
|
||||||
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span>
|
<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>
|
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
|
||||||
<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-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-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button>
|
||||||
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button>
|
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button>
|
||||||
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
|
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
|
||||||
</div>
|
</div></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() {
|
_template() {
|
||||||
return `
|
return `
|
||||||
<div class="scene-header animated">
|
<div class="scene-header animated" id="scene-list-container">
|
||||||
<div>
|
<div>
|
||||||
<h2>场景管理</h2>
|
<h2>场景管理</h2>
|
||||||
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;">
|
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}</p>
|
||||||
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;">
|
<div style="display:flex;gap:8px">
|
||||||
<button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button>
|
<button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button>
|
||||||
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>
|
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>+ 新建场景</button>
|
||||||
+ 新建场景
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="scene-grid" class="scene-grid animated"></div>`;
|
<div id="scene-grid" class="scene-grid animated"></div>
|
||||||
|
|
||||||
|
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
|
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">← 返回</button>
|
||||||
|
<input id="edit-scene-name" placeholder="场景名称" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
|
||||||
|
</div>
|
||||||
|
<button id="btn-save-scene" class="btn btn-primary btn-sm">保存场景</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||||||
|
<div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px">
|
||||||
|
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">仪表盘列表 (拖到画布)</h4>
|
||||||
|
<div id="editor-dash-list"></div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
|
||||||
|
<div id="editor-canvas" style="position:relative;background:rgba(255,255,255,.02);margin:0 auto;overflow:hidden;transition:width 0.2s,height 0.2s"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+155
-11
@@ -11,6 +11,12 @@ const PageSettings = {
|
|||||||
const forwards = await this._getForwards();
|
const forwards = await this._getForwards();
|
||||||
this._el.innerHTML = this._template(cfg, games, forwards);
|
this._el.innerHTML = this._template(cfg, games, forwards);
|
||||||
this._bindEvents();
|
this._bindEvents();
|
||||||
|
this._toggleGamePorts(cfg.use_unified_port !== false);
|
||||||
|
},
|
||||||
|
|
||||||
|
_toggleGamePorts(hide) {
|
||||||
|
const el = document.getElementById('game-ports-section');
|
||||||
|
if (el) el.style.display = hide ? 'none' : 'block';
|
||||||
},
|
},
|
||||||
|
|
||||||
async _getForwards() {
|
async _getForwards() {
|
||||||
@@ -25,7 +31,13 @@ const PageSettings = {
|
|||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
|
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
|
||||||
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
|
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
|
||||||
Toast.show('遥测端口已更新,重启监听后生效', 'info');
|
Toast.show('端口已更新,重启后生效', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
|
||||||
|
await API.updateConfig({ use_unified_port: e.target.checked });
|
||||||
|
this._toggleGamePorts(e.target.checked);
|
||||||
|
Toast.show('已更新', 'info');
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
|
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
|
||||||
@@ -80,6 +92,89 @@ const PageSettings = {
|
|||||||
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
|
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-export-backup')?.addEventListener('click', () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/backup/export';
|
||||||
|
a.download = 'turbosu_backup.tsb';
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-import-backup')?.addEventListener('click', () => {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsb';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
const res = await fetch('/api/backup/import', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.ok ? data.message || '恢复成功' : '恢复失败: ' + data.message, data.ok ? 'success' : 'error');
|
||||||
|
}; input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-restart-server')?.addEventListener('click', async () => {
|
||||||
|
if (!confirm('确定重启 TurboSu 服务?')) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/restart', { method: 'POST' });
|
||||||
|
Toast.show('服务正在重启,3秒后自动刷新...', 'info');
|
||||||
|
setTimeout(() => location.reload(), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show('重启请求已发送,请手动刷新', 'info');
|
||||||
|
setTimeout(() => location.reload(), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach(el => {
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = el.getAttribute('data-target');
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach(s => s.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-rec-start')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/record/start', { method: 'POST' });
|
||||||
|
document.getElementById('btn-rec-start').style.display = 'none';
|
||||||
|
document.getElementById('btn-rec-stop').style.display = '';
|
||||||
|
document.getElementById('rec-status').textContent = '录制中...';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/record/stop', { method: 'POST' });
|
||||||
|
document.getElementById('btn-rec-start').style.display = '';
|
||||||
|
document.getElementById('btn-rec-stop').style.display = 'none';
|
||||||
|
document.getElementById('rec-status').textContent = '已停止';
|
||||||
|
this._loadRecordings();
|
||||||
|
});
|
||||||
|
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
|
||||||
|
await fetch('/api/record/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: e.target.checked }) });
|
||||||
|
});
|
||||||
|
this._loadRecordings();
|
||||||
|
|
||||||
|
document.getElementById('settings-test-mode')?.addEventListener('change', async (e) => {
|
||||||
|
const on = e.target.checked;
|
||||||
|
await API.updateConfig({ ui_test_mode: on });
|
||||||
|
if (on) {
|
||||||
|
await fetch('/api/test-mode/start', { method: 'POST' });
|
||||||
|
Toast.show('测试模式已开启', 'success');
|
||||||
|
} else {
|
||||||
|
await fetch('/api/test-mode/stop', { method: 'POST' });
|
||||||
|
Toast.show('测试模式已关闭', 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-game-ports')?.addEventListener('click', async () => {
|
||||||
|
const rows = document.querySelectorAll('#game-ports-section .game-port-row');
|
||||||
|
const gamePorts = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
const id = r.querySelector('.gp-id')?.textContent;
|
||||||
|
const port = parseInt(r.querySelector('.gp-port')?.value) || 0;
|
||||||
|
if (id && port > 0) gamePorts[id] = port;
|
||||||
|
});
|
||||||
|
await API.updateConfig({ game_ports: gamePorts });
|
||||||
|
Toast.show('端口已保存', 'success');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_addForward() {
|
_addForward() {
|
||||||
@@ -87,7 +182,7 @@ const PageSettings = {
|
|||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'forward-row glass-card';
|
row.className = 'forward-row glass-card';
|
||||||
row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap';
|
row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap';
|
||||||
row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" checked> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button>';
|
row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label class="switch-label"><input type="checkbox" class="forward-toggle" checked><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -104,9 +199,39 @@ const PageSettings = {
|
|||||||
this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success'));
|
this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success'));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async _loadRecordings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recordings');
|
||||||
|
const list = await res.json();
|
||||||
|
const el = document.getElementById('recording-list');
|
||||||
|
if (!el) return;
|
||||||
|
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:12px">暂无录制文件</p>'; return; }
|
||||||
|
el.innerHTML = list.map(f => {
|
||||||
|
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
||||||
|
return '<div class="glass-card" style="padding:8px 12px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">' + f.name + ' <span style="color:var(--text-tertiary)">' + size + '</span></span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button></div>';
|
||||||
|
}).join('');
|
||||||
|
el.querySelectorAll('.btn-play-rec').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) });
|
||||||
|
Toast.show('回放已开始', 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_gamePortListHtml(games, cfg) {
|
||||||
|
const gamePorts = cfg.game_ports || {};
|
||||||
|
return games.map(g => {
|
||||||
|
const proto = g.protocol || 'udp';
|
||||||
|
const mode = g.mode || (proto === 'tcp' ? 'server' : '');
|
||||||
|
const protoLabel = proto.toUpperCase() + (mode ? '/' + mode : '');
|
||||||
|
return '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:120px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><span style="width:70px;font-size:11px;color:var(--text-tertiary)">' + protoLabel + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">默认: ' + (g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '</span></div>';
|
||||||
|
}).join('');
|
||||||
|
},
|
||||||
|
|
||||||
_forwardListHtml(forwards) {
|
_forwardListHtml(forwards) {
|
||||||
if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">暂无转发目标,点击下方添加</p>';
|
if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">暂无转发目标</p>';
|
||||||
return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button></div>').join('');
|
return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label class="switch-label"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button></div>').join('');
|
||||||
},
|
},
|
||||||
|
|
||||||
_gamePluginListHtml(games) {
|
_gamePluginListHtml(games) {
|
||||||
@@ -115,19 +240,38 @@ const PageSettings = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_template(cfg, games, forwards) {
|
_template(cfg, games, forwards) {
|
||||||
return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:24px">设置</h2>' +
|
const sections = [
|
||||||
|
{ id: 'sec-forward', label: '数据转发' },
|
||||||
|
{ id: 'sec-connection', label: '连接设置' },
|
||||||
|
{ id: 'sec-dashboards', label: '仪表盘管理' },
|
||||||
|
{ id: 'sec-plugins', label: '游戏插件' },
|
||||||
|
{ id: 'sec-backup', label: '数据备份' },
|
||||||
|
{ id: 'sec-dev', label: '开发工具' },
|
||||||
|
{ id: 'sec-system', label: '系统' },
|
||||||
|
];
|
||||||
|
|
||||||
'<div class="settings-section"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' +
|
return '<div class="settings-layout"><div class="settings-nav">' +
|
||||||
|
sections.map(s => '<a class="settings-nav-item" data-target="' + s.id + '">' + s.label + '</a>').join('') +
|
||||||
|
'</div><div class="settings-main"><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">游戏内 UDP 输出端口 (默认 20777)</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">重启遥测监听</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" id="sec-forward"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' +
|
||||||
|
|
||||||
'<div class="settings-section"><h3>仪表盘管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></div></div>' +
|
'<div class="settings-section" id="sec-connection"><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">使用游戏独立端口</div><div class="settings-desc">关闭后切游戏自动切换到各游戏的默认端口</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '><span class="switch-track"></span></label></div></div><div id="game-ports-section" style="margin-top:8px;padding:12px;background:var(--bg-tertiary);border-radius:8px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">各游戏独立端口</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">保存端口</button></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">导入 .tsp</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. 创建包含 manifest.json + parser.py 的文件夹<br>2. manifest.json 定义元信息,parser.py 实现 get_parser() 函数<br>3. 返回对象实现 game_id() 和 parse(data, addr) 方法<br>4. 打包 zip 改后缀 .tsp 即可导入<br>5. TelemetryData 字段参考 server/telemetry/data.py</div></div>' +
|
'<div class="settings-section" id="sec-dashboards"><h3>仪表盘管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></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 · Yei.J. (AskaEth)</div></div></div></div>' +
|
'<div class="settings-section" id="sec-plugins"><h3>游戏插件管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">导入 .tsp</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">重新加载</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div></div>' +
|
||||||
|
|
||||||
'</div>';
|
'<div class="settings-section" id="sec-backup"><h3>数据备份</h3><div class="settings-row"><div><div class="settings-label">导出全量备份</div><div class="settings-desc">打包仪表盘+场景+配置为 .tsb</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">下载 .tsb</button></div></div><div class="settings-row"><div><div class="settings-label">恢复备份</div><div class="settings-desc">从 .tsb 文件恢复</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">导入 .tsb</button></div></div></div>' +
|
||||||
|
|
||||||
|
|
||||||
|
'<div class="settings-section" id="sec-dev"><h3>开发工具</h3><div class="settings-row"><div><div class="settings-label">UI 测试模式</div><div class="settings-desc">本地随机模拟遥测数据</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '><span class="switch-track"></span></label></div></div></div>' +
|
||||||
|
|
||||||
|
'<div class="settings-section" id="sec-system"><h3>系统</h3><div class="settings-row"><div><div class="settings-label">重启 TurboSu</div><div class="settings-desc">重新加载所有代码和配置</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">重启服务</button></div></div></div>' +
|
||||||
|
|
||||||
|
'<div class="settings-section"><h3>关于</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">赛车遥测仪表盘 · 极速中枢 · Yei.J. (AskaEth)<br><a href="https://git.yeij.top/AskaEth/TurboSu" target="_blank" style="font-size:11px;color:var(--accent)">https://git.yeij.top/AskaEth/TurboSu</a></div></div></div></div>' +
|
||||||
|
|
||||||
|
'</div></div></div>';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const Router = {
|
|||||||
dashboard: PageDashboard,
|
dashboard: PageDashboard,
|
||||||
scene: PageScene,
|
scene: PageScene,
|
||||||
debug: PageDebug,
|
debug: PageDebug,
|
||||||
|
record: PageRecord,
|
||||||
|
market: PageMarket,
|
||||||
settings: PageSettings,
|
settings: PageSettings,
|
||||||
},
|
},
|
||||||
_currentPage: 'home',
|
_currentPage: 'home',
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const Lang = {
|
||||||
|
_current: 'zh',
|
||||||
|
_strings: {
|
||||||
|
zh: {
|
||||||
|
home: '首页', dashboard: '仪表盘', scene: '场景', settings: '设置',
|
||||||
|
debug: '数据测试', selectGame: '选择游戏', collapsed: '折叠侧边栏',
|
||||||
|
connected: '已连接', disconnected: '未连接', speed: '实时速度', rpm: '实时转速',
|
||||||
|
gear: '当前档位', server: '服务器状态', telemetry: '遥测监听',
|
||||||
|
connections: 'WebSocket 连接', packets: '数据包接收', lastPacket: '最后数据包',
|
||||||
|
currentGame: '当前游戏', systemOverview: '系统概览', running: '运行中',
|
||||||
|
stopped: '已停止', notStarted: '未启动', noData: '暂无数据', client: '个客户端',
|
||||||
|
notSelected: '未选择', packetsUnit: '包', secAgo: '秒前',
|
||||||
|
importTsd: '导入 .tsd', importTss: '导入 .tss', importTsp: '导入 .tsp',
|
||||||
|
export: '导出', delete: '删除', edit: '编辑', render: '渲染', save: '保存',
|
||||||
|
cancel: '取消', create: '新建场景', sceneName: '场景名称',
|
||||||
|
search: '搜索仪表盘...', sort: '排序', all: '全部', none: '暂无',
|
||||||
|
forward: '数据转发', addTarget: '+ 添加目标', saveActive: '保存并生效',
|
||||||
|
connectionSettings: '连接设置', telemetryPort: '遥测监听端口',
|
||||||
|
unifiedPort: '统一端口', perGamePort: '各游戏独立端口',
|
||||||
|
restart: '重启监听', pluginSettings: '游戏插件管理',
|
||||||
|
plugins: '游戏插件', reload: '重新加载', backup: '数据备份',
|
||||||
|
exportBackup: '下载 .tsb', importBackup: '导入 .tsb',
|
||||||
|
devTools: '开发工具', testMode: 'UI 测试模式',
|
||||||
|
system: '系统', restartServer: '重启服务',
|
||||||
|
about: '关于', author: 'Yei.J. (AskaEth)',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
home: 'Home', dashboard: 'Dashboard', scene: 'Scene', settings: 'Settings',
|
||||||
|
debug: 'Debug', selectGame: 'Select Game', collapsed: 'Collapse',
|
||||||
|
connected: 'Connected', disconnected: 'Disconnected', speed: 'Speed', rpm: 'RPM',
|
||||||
|
gear: 'Gear', server: 'Server', telemetry: 'Telemetry',
|
||||||
|
connections: 'Connections', packets: 'Packets', lastPacket: 'Last Packet',
|
||||||
|
currentGame: 'Game', systemOverview: 'Overview', running: 'Running',
|
||||||
|
stopped: 'Stopped', notStarted: 'Off', noData: 'No Data', client: ' clients',
|
||||||
|
notSelected: 'None', packetsUnit: 'pkt', secAgo: 's ago',
|
||||||
|
importTsd: 'Import .tsd', importTss: 'Import .tss', importTsp: 'Import .tsp',
|
||||||
|
export: 'Export', delete: 'Delete', edit: 'Edit', render: 'Render', save: 'Save',
|
||||||
|
cancel: 'Cancel', create: 'New Scene', sceneName: 'Name',
|
||||||
|
search: 'Search...', sort: 'Sort', all: 'All', none: 'None',
|
||||||
|
forward: 'Forward', addTarget: '+ Add', saveActive: 'Save',
|
||||||
|
connectionSettings: 'Connection', telemetryPort: 'Port',
|
||||||
|
unifiedPort: 'Unified', perGamePort: 'Per Game',
|
||||||
|
restart: 'Restart', pluginSettings: 'Plugins',
|
||||||
|
plugins: 'Plugins', reload: 'Reload', backup: 'Backup',
|
||||||
|
exportBackup: 'Download .tsb', importBackup: 'Import .tsb',
|
||||||
|
devTools: 'Dev Tools', testMode: 'Test Mode',
|
||||||
|
system: 'System', restartServer: 'Restart',
|
||||||
|
about: 'About', author: 'Yei.J. (AskaEth)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._current = localStorage.getItem('turbosu-lang') || 'zh';
|
||||||
|
const btn = document.getElementById('lang-toggle');
|
||||||
|
if (btn) btn.textContent = this._current === 'zh' ? 'EN' : '中';
|
||||||
|
},
|
||||||
|
|
||||||
|
t(key) {
|
||||||
|
return (this._strings[this._current] || this._strings.zh)[key] || key;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this._current = this._current === 'zh' ? 'en' : 'zh';
|
||||||
|
localStorage.setItem('turbosu-lang', this._current);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Lang = Lang;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const Theme = {
|
const Theme = {
|
||||||
_current: 'dark',
|
_current: 'dark',
|
||||||
|
_toggling: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._current = localStorage.getItem('turbosu-theme') || 'dark';
|
this._current = localStorage.getItem('turbosu-theme') || 'dark';
|
||||||
@@ -7,8 +8,11 @@ const Theme = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
if (this._toggling) return;
|
||||||
|
this._toggling = true;
|
||||||
this._current = this._current === 'dark' ? 'light' : 'dark';
|
this._current = this._current === 'dark' ? 'light' : 'dark';
|
||||||
this.apply();
|
this.apply();
|
||||||
|
setTimeout(() => { this._toggling = false; }, 200);
|
||||||
},
|
},
|
||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
|
|||||||
@@ -13,15 +13,26 @@ const Toast = {
|
|||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast ${type}`;
|
toast.className = `toast ${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
|
toast.style.cursor = 'pointer';
|
||||||
|
toast.addEventListener('click', () => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transform = 'translateY(20px)';
|
||||||
|
toast.style.transition = 'all 0.2s ease';
|
||||||
|
setTimeout(() => toast.remove(), 200);
|
||||||
|
});
|
||||||
this._container.appendChild(toast);
|
this._container.appendChild(toast);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
toast.style.opacity = '0';
|
toast.style.opacity = '0';
|
||||||
toast.style.transform = 'translateX(100%)';
|
toast.style.transform = 'translateY(20px)';
|
||||||
toast.style.transition = 'all 0.3s ease';
|
toast.style.transition = 'all 0.3s ease';
|
||||||
setTimeout(() => toast.remove(), 300);
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}
|
||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.Toast = Toast;
|
window.Toast = Toast;
|
||||||
|
|||||||
+17
-3
@@ -5,15 +5,26 @@ const WS = {
|
|||||||
_reconnectDelay: 2000,
|
_reconnectDelay: 2000,
|
||||||
_handlers: {},
|
_handlers: {},
|
||||||
_connected: false,
|
_connected: false,
|
||||||
|
_lastPong: 0,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
this._url = `${protocol}//${location.host}/ws`;
|
this._url = `${protocol}//${location.host}/ws`;
|
||||||
this._connect();
|
this._connect();
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden && !this._connected) {
|
||||||
|
this._reconnectDelay = 500;
|
||||||
|
this._connect();
|
||||||
|
}
|
||||||
|
if (!document.hidden && this._connected) {
|
||||||
|
this.send({type: 'ping'});
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_connect() {
|
_connect() {
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN) return;
|
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._ws = new WebSocket(this._url);
|
this._ws = new WebSocket(this._url);
|
||||||
@@ -24,6 +35,7 @@ const WS = {
|
|||||||
|
|
||||||
this._ws.onopen = () => {
|
this._ws.onopen = () => {
|
||||||
this._connected = true;
|
this._connected = true;
|
||||||
|
this._lastPong = Date.now();
|
||||||
this._reconnectDelay = 2000;
|
this._reconnectDelay = 2000;
|
||||||
this._emit('connection', true);
|
this._emit('connection', true);
|
||||||
this._emit('status', 'connected');
|
this._emit('status', 'connected');
|
||||||
@@ -32,11 +44,12 @@ const WS = {
|
|||||||
this._ws.onmessage = (event) => {
|
this._ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.type === 'pong') { this._lastPong = Date.now(); return; }
|
||||||
this._emit(msg.type || 'message', msg.data || msg);
|
this._emit(msg.type || 'message', msg.data || msg);
|
||||||
if (msg.type === 'telemetry') {
|
if (msg.type === 'telemetry') {
|
||||||
this._emit('telemetry', msg.data);
|
this._emit('telemetry', msg.data);
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) { console.error(e); }
|
||||||
};
|
};
|
||||||
|
|
||||||
this._ws.onclose = () => {
|
this._ws.onclose = () => {
|
||||||
@@ -47,7 +60,8 @@ const WS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this._ws.onerror = () => {
|
this._ws.onerror = () => {
|
||||||
this._emit('status', 'error');
|
this._emit('status', 'disconnected');
|
||||||
|
this._connected = false;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "TurboSu - 极速中枢",
|
||||||
|
"short_name": "TurboSu",
|
||||||
|
"description": "赛车遥测仪表盘",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#060a16",
|
||||||
|
"theme_color": "#060a16",
|
||||||
|
"icons": [
|
||||||
|
{"src": "/static/img/logo.svg", "sizes": "any", "type": "image/svg+xml"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
self.addEventListener('install', (e) => { self.skipWaiting(); });
|
||||||
|
self.addEventListener('activate', (e) => { e.waitUntil(clients.claim()); });
|
||||||
|
self.addEventListener('fetch', (e) => { e.respondWith(fetch(e.request).catch(() => caches.match(e.request))); });
|
||||||
+34
-11
@@ -3,20 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>TurboSu - 赛车遥测仪表盘</title>
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
||||||
|
<title>TurboSu - 极速中枢</title>
|
||||||
<link rel="stylesheet" href="/static/css/miuix.css">
|
<link rel="stylesheet" href="/static/css/miuix.css">
|
||||||
<link rel="stylesheet" href="/static/css/main.css">
|
<link rel="stylesheet" href="/static/css/main.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="theme-dark">
|
<body class="theme-dark">
|
||||||
|
<div id="sidebar-backdrop" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:199" onclick="toggleMobileMenu()"></div>
|
||||||
<div id="app" class="app-layout">
|
<div id="app" class="app-layout">
|
||||||
<aside id="sidebar" class="sidebar expanded">
|
<aside id="sidebar" class="sidebar expanded">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="sidebar-logo">
|
<div class="sidebar-logo">
|
||||||
<svg viewBox="0 0 48 48" width="40" height="40">
|
<img src="/static/img/logo.svg" alt="TurboSu" 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>
|
||||||
<div class="sidebar-title">TurboSu</div>
|
<div class="sidebar-title">TurboSu</div>
|
||||||
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
|
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
|
||||||
@@ -56,6 +55,14 @@
|
|||||||
<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>
|
<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>
|
<span class="nav-label">数据测试</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#/record" class="nav-item" data-route="record">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">录制回放</span>
|
||||||
|
</a>
|
||||||
|
<a href="#/market" class="nav-item" data-route="market">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z" fill="currentColor"/></svg>
|
||||||
|
<span class="nav-label">市场</span>
|
||||||
|
</a>
|
||||||
<a href="#/settings" class="nav-item" data-route="settings">
|
<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>
|
<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>
|
<span class="nav-label">设置</span>
|
||||||
@@ -66,11 +73,11 @@
|
|||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header id="topbar" class="topbar">
|
<header id="topbar" class="topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
<div id="topbar-logo-title" class="topbar-logo-title hidden">
|
<button id="mobile-menu-btn" class="icon-btn" style="display:none" onclick="toggleMobileMenu()">
|
||||||
<svg viewBox="0 0 48 48" width="28" height="28">
|
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/></svg>
|
||||||
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
|
</button>
|
||||||
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
|
<div id="topbar-logo-title" class="topbar-logo-title hidden" style="cursor:pointer" onclick="toggleMobileMenu()">
|
||||||
</svg>
|
<img src="/static/img/logo.svg" alt="TurboSu" width="28" height="28">
|
||||||
<span class="topbar-title-text">TurboSu</span>
|
<span class="topbar-title-text">TurboSu</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +90,7 @@
|
|||||||
<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-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>
|
<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>
|
</button>
|
||||||
|
<button id="lang-toggle" class="icon-btn" title="Language" style="font-size:13px;font-weight:600">中</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -95,6 +103,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="page-debug" class="page">
|
<div id="page-debug" class="page">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="page-record" class="page">
|
||||||
|
</div>
|
||||||
|
<div id="page-market" class="page">
|
||||||
|
</div>
|
||||||
<div id="page-settings" class="page">
|
<div id="page-settings" class="page">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,6 +115,7 @@
|
|||||||
|
|
||||||
<script src="/static/js/utils/theme.js"></script>
|
<script src="/static/js/utils/theme.js"></script>
|
||||||
<script src="/static/js/utils/toast.js"></script>
|
<script src="/static/js/utils/toast.js"></script>
|
||||||
|
<script src="/static/js/utils/lang.js"></script>
|
||||||
<script src="/static/js/api.js"></script>
|
<script src="/static/js/api.js"></script>
|
||||||
<script src="/static/js/ws.js"></script>
|
<script src="/static/js/ws.js"></script>
|
||||||
<script src="/static/js/components/sidebar.js"></script>
|
<script src="/static/js/components/sidebar.js"></script>
|
||||||
@@ -111,8 +124,18 @@
|
|||||||
<script src="/static/js/pages/dashboard.js"></script>
|
<script src="/static/js/pages/dashboard.js"></script>
|
||||||
<script src="/static/js/pages/scene.js"></script>
|
<script src="/static/js/pages/scene.js"></script>
|
||||||
<script src="/static/js/pages/debug.js"></script>
|
<script src="/static/js/pages/debug.js"></script>
|
||||||
|
<script src="/static/js/pages/record.js"></script>
|
||||||
|
<script src="/static/js/pages/market.js"></script>
|
||||||
<script src="/static/js/pages/settings.js"></script>
|
<script src="/static/js/pages/settings.js"></script>
|
||||||
<script src="/static/js/router.js"></script>
|
<script src="/static/js/router.js"></script>
|
||||||
<script src="/static/js/app.js"></script>
|
<script src="/static/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
function toggleMobileMenu() {
|
||||||
|
var sb = document.getElementById('sidebar');
|
||||||
|
var bd = document.getElementById('sidebar-backdrop');
|
||||||
|
var open = sb.classList.toggle('mobile-open');
|
||||||
|
bd.style.display = open ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -124,8 +124,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
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 vw = window.innerWidth;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const scale = Math.min(vw / cw, vh / ch);
|
const scale = Math.min(vw / cw, vh / ch);
|
||||||
|
|||||||
+39
-12
@@ -1,36 +1,63 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
|
||||||
LOG_DIR.mkdir(exist_ok=True)
|
LOG_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
LOG_FILE = LOG_DIR / "turbosu.log"
|
|
||||||
LOG_FORMAT = logging.Formatter(
|
LOG_FORMAT = logging.Formatter(
|
||||||
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MAX_LOG_FILES = 20
|
||||||
|
|
||||||
|
_root_handler: RotatingFileHandler | None = None
|
||||||
|
_console_handler: logging.StreamHandler | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _init_handlers():
|
||||||
|
global _root_handler, _console_handler
|
||||||
|
if _root_handler:
|
||||||
|
return
|
||||||
|
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
uid = uuid.uuid4().hex[:6]
|
||||||
|
log_file = LOG_DIR / f"turbosu_{ts}_{uid}.log"
|
||||||
|
|
||||||
|
_root_handler = RotatingFileHandler(
|
||||||
|
log_file, maxBytes=10 * 1024 * 1024, backupCount=20, encoding="utf-8"
|
||||||
|
)
|
||||||
|
_root_handler.setLevel(logging.DEBUG)
|
||||||
|
_root_handler.setFormatter(LOG_FORMAT)
|
||||||
|
|
||||||
|
_console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
_console_handler.setLevel(logging.INFO)
|
||||||
|
_console_handler.setFormatter(LOG_FORMAT)
|
||||||
|
|
||||||
|
logs = sorted(LOG_DIR.glob("turbosu_*.log*"), key=lambda p: p.stat().st_mtime)
|
||||||
|
while len(logs) > MAX_LOG_FILES:
|
||||||
|
oldest = logs.pop(0)
|
||||||
|
try:
|
||||||
|
oldest.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
|
def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
|
||||||
|
_init_handlers()
|
||||||
|
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(level)
|
logger.setLevel(level)
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
|
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
fh = RotatingFileHandler(
|
logger.addHandler(_root_handler)
|
||||||
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
logger.addHandler(_console_handler)
|
||||||
)
|
|
||||||
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
|
return logger
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user