Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|
||||||
# 或手动启动
|
# 或手动启动
|
||||||
|
|||||||
@@ -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,25 @@ 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("/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 +131,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,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"sidebar_collapsed": False,
|
"sidebar_collapsed": False,
|
||||||
"telemetry_port": 20777,
|
"telemetry_port": 20777,
|
||||||
|
"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,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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -112,11 +112,32 @@ class TelemetryListener:
|
|||||||
|
|
||||||
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.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 +151,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,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);
|
||||||
|
|||||||
@@ -21,7 +21,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>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+150
-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,34 @@ 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 => '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:140px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || 20777) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">默认: ' + (g.default_port || 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 +235,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,67 @@
|
|||||||
|
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';
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ const WS = {
|
|||||||
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 = () => {
|
||||||
|
|||||||
@@ -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