122 Commits

Author SHA1 Message Date
AskaEth 6c4e61f0c0 fix: mobile sidebar display toggle instead of transform 2026-07-27 12:01:19 +08:00
AskaEth e8c3c85193 fix: mobile sidebar visibility and shadow when open 2026-07-27 11:58:24 +08:00
AskaEth 91c4ed7dab fix: mobile sidebar toggle with backdrop, miuix tap style 2026-07-27 11:56:10 +08:00
AskaEth 877e6c1a8c feat: tap logo to toggle sidebar on mobile 2026-07-27 11:53:46 +08:00
AskaEth b60b35cbea fix: mobile sidebar with hamburger toggle 2026-07-27 11:50:10 +08:00
AskaEth 22bfd6fc19 fix: add scene preview image route 2026-07-27 11:43:43 +08:00
AskaEth 1260566e75 fix: serve dashboard preview images via /dashboard/{id}/preview.{ext} 2026-07-27 11:42:04 +08:00
AskaEth 16537a6f49 refactor: scenes use manifest.json (same structure as dashboards) 2026-07-27 11:39:42 +08:00
AskaEth 7a03fd3ebd refactor: scenes now folder-based with preview image support 2026-07-27 11:36:25 +08:00
AskaEth 30697e4a64 feat: market cards show preview images for scenes too 2026-07-27 11:33:19 +08:00
AskaEth 958482e5e6 feat: auto-detect preview image format (jpg/png/webp/gif) 2026-07-27 11:32:26 +08:00
AskaEth 64bf6d9603 feat: dashboard cards show preview.jpg background when available 2026-07-27 11:31:19 +08:00
AskaEth c4035acf0c feat: Forza composite dashboard preview image 2026-07-27 11:30:56 +08:00
AskaEth d931e4b2b0 fix: show error detail + retry link, Miuix refresh button 2026-07-27 11:27:30 +08:00
AskaEth 6dd91739f0 feat: refresh button in market tab bar 2026-07-27 11:25:34 +08:00
AskaEth 978ccacd17 fix: switch grid display for 'my' tab vs market tabs 2026-07-27 11:22:49 +08:00
AskaEth 32a98d0e84 fix: my tab card layout - info below title 2026-07-27 10:53:26 +08:00
AskaEth 97de708469 feat: update button opens full publish modal, keeps old preview if not changed 2026-07-27 10:51:36 +08:00
AskaEth 2d5cdc0d3b feat: drag-drop preview upload with file type/size validation 2026-07-27 10:46:08 +08:00
AskaEth 44c0383dc1 fix: 6MB limit for preview images (client + server) 2026-07-27 10:44:12 +08:00
AskaEth 495cc4143f feat: optional preview image upload in publish modal 2026-07-27 10:42:14 +08:00
AskaEth 5665c86eb3 fix: restore game_id and author in scene publish-local 2026-07-27 10:34:18 +08:00
AskaEth 977ca89965 fix: restore manifest_id for scene publish 2026-07-27 10:31:10 +08:00
AskaEth 003c303231 fix: remove manifest_id from scene publish (field not in collection) 2026-07-27 10:29:54 +08:00
AskaEth cc04897d17 feat: collapsible sections in publish modal 2026-07-27 10:17:04 +08:00
AskaEth 3ea3f10100 fix: replace _upload with publish modal (file picker + local list) 2026-07-27 10:14:14 +08:00
AskaEth 2960d132ef feat: publish-from-local with builtin/market filter, file picker modal 2026-07-27 10:11:00 +08:00
AskaEth e94d862560 chore: username placeholder -> 必填 2026-07-27 10:04:16 +08:00
AskaEth c59c7a172c refactor: login modal with tabs, username only on register 2026-07-27 10:03:13 +08:00
AskaEth 08e5729de3 fix: match installed by manifest_id only, no name fallback 2026-07-27 09:53:07 +08:00
AskaEth 6a58ec9d20 fix: re-add localNames check for installed matching 2026-07-27 09:51:42 +08:00
AskaEth 49db12910d fix: require token for upload prefix generation 2026-07-27 09:49:31 +08:00
AskaEth 7e5e5f4afa feat: prefix manifest_id with user token hash to prevent ID collisions 2026-07-27 09:48:49 +08:00
AskaEth a6adedaadb feat: create new dashboard button with auto-generated UUID 2026-07-27 09:47:07 +08:00
AskaEth b8f07c0cba feat: auto-generate UUID if manifest.json lacks id 2026-07-27 09:45:34 +08:00
AskaEth e3afb2ac9b fix: dual id/name matching for installed + console.error on all catches 2026-07-27 09:31:26 +08:00
AskaEth 1c73ec9db6 fix: remove undefined localNames reference 2026-07-27 09:29:37 +08:00
AskaEth 10aa14b6d5 fix: add console.error to market catch blocks for debugging 2026-07-27 09:28:34 +08:00
AskaEth ba2481139b fix: hardcode market_url fallback to turbosu.yeij.top 2026-07-27 09:25:04 +08:00
AskaEth fb5ebfe162 fix: check local scenes for installed status 2026-07-27 09:19:30 +08:00
AskaEth 87608868f5 fix: use dual id+name matching, manifest_id pending field addition 2026-07-27 09:01:18 +08:00
AskaEth 0fd96b7794 fix: refresh market grid after install 2026-07-27 08:59:17 +08:00
AskaEth 76d9cc3975 fix: match installed by manifest_id only, add manifest_id field 2026-07-27 08:54:52 +08:00
AskaEth ae4bffd79f fix: match installed by both id and name 2026-07-27 08:51:31 +08:00
AskaEth 2a51026587 feat: uninstall button for locally installed market packages 2026-07-27 08:49:20 +08:00
AskaEth d04ab1138c feat: market checks local installs, shows installed badge 2026-07-27 08:47:35 +08:00
AskaEth 3ffee330a5 fix: market install button feedback - always show toast 2026-07-27 08:44:53 +08:00
AskaEth 7e3456d208 chore: remove admin.html from repo 2026-07-27 08:38:25 +08:00
AskaEth 55b1539487 feat: admin review panel as standalone local file 2026-07-27 08:38:09 +08:00
AskaEth b3ec4a37e2 fix: G-force longitudinal sign direction 2026-07-27 08:27:19 +08:00
AskaEth 5064db0032 fix: G-force uses acceleration_z for longitudinal axis 2026-07-27 08:24:25 +08:00
AskaEth c65ae4035b fix: realistic G-force test data with jitter and vertical bounce 2026-07-27 08:20:45 +08:00
AskaEth 40087e1c16 fix: favicon uses logo.svg 2026-07-27 08:17:26 +08:00
AskaEth c051fdbfaf fix: serve favicon.ico -> SVG to prevent 404 errors 2026-07-27 08:17:03 +08:00
AskaEth 8c78393fb5 fix: add acceleration values to test packet for G-force dashboard 2026-07-27 08:15:19 +08:00
AskaEth d3a269b5cd fix: remove extra closing div in dashboard template 2026-07-27 00:41:40 +08:00
AskaEth 55a1fb00df fix: remove crash-causing market references from dashboard.js 2026-07-26 23:56:49 +08:00
AskaEth 865a239a7e docs: add official website link 2026-07-26 23:18:14 +08:00
AskaEth 93d5f38431 docs: changelog date corrected 2026-07-26 23:16:57 +08:00
AskaEth 89b44f1288 docs: update README - 极速中枢, 8 dashboards, market, recording 2026-07-26 23:16:34 +08:00
AskaEth 93d4ec308a fix: remove -created sort, field not in base collection 2026-07-26 22:52:12 +08:00
AskaEth f9256a564b feat: my tab shows time/version/status, restore -created sort 2026-07-26 22:26:43 +08:00
AskaEth df8f33dcca fix: market tab styles + my items layout 2026-07-26 22:23:23 +08:00
AskaEth 5e3d565b38 fix: remove -created sort (field doesnt exist), use -name 2026-07-26 22:05:29 +08:00
AskaEth b434be2fd1 fix: dashb_pending boolean as string for form data 2026-07-26 21:54:42 +08:00
AskaEth b9359b3b47 feat: '我的' tab - user content management with update/delete 2026-07-26 21:46:24 +08:00
AskaEth 59887d7d46 feat: PWA icon + download count increment on market install 2026-07-26 21:43:14 +08:00
AskaEth 98440099a8 fix: market tab active state inline style update 2026-07-26 21:39:54 +08:00
AskaEth 47341f1f76 feat: market scenes tab - upload/install with dependency check 2026-07-26 21:37:24 +08:00
AskaEth e6344b1916 feat: validate .tsd on upload, auto-extract manifest metadata 2026-07-26 21:34:36 +08:00
AskaEth c4675f7a15 feat: persistent registration toast, click to dismiss 2026-07-26 21:32:01 +08:00
AskaEth 605830ac34 feat: username field in market registration 2026-07-26 21:25:33 +08:00
AskaEth 6cb0759630 fix: remove hardcoded email placeholder 2026-07-26 21:23:13 +08:00
AskaEth 93776c7d98 refactor: market as standalone sidebar page, removed from dashboard 2026-07-26 21:20:27 +08:00
AskaEth f4a0869af5 fix: market auth error handling, identity field forwarding 2026-07-26 21:16:16 +08:00
AskaEth 5735602f7d refactor: separate market from categories in sub-sidebar 2026-07-26 21:11:40 +08:00
AskaEth 242e451ca7 feat: CHANGELOG.json as single source of truth 2026-07-26 21:07:15 +08:00
AskaEth 273bb8f7fb fix: use dashb_pending field name for upload approval 2026-07-26 20:57:07 +08:00
AskaEth 91ee54c2c5 fix: market upload sets status=pending by default 2026-07-26 20:54:37 +08:00
AskaEth 70df32bb07 feat: market login/logout/upload flow with UI state management 2026-07-26 20:44:11 +08:00
AskaEth 3bb4de6de6 fix: PageRecord.init() missing in app.js 2026-07-26 20:41:15 +08:00
AskaEth 1303529d31 chore: add favicon 2026-07-26 20:37:01 +08:00
AskaEth 8da69b5ef8 feat: custom SVG logo in sidebar and topbar 2026-07-26 20:35:31 +08:00
AskaEth e7e93d9664 style: 极速中枢 display name in WebUI 2026-07-26 20:25:59 +08:00
AskaEth 3db849b681 chore: market_url to production https://turbosu.yeij.top 2026-07-26 20:14:33 +08:00
AskaEth 683e573e9a feat: marketplace integration via backend proxy
- market_url config (server-side only, never exposed to frontend)
- /api/market/* proxy endpoints to PocketBase
- Dashboard page: 'Market' tab in sub-sidebar
- Market list: browse/install community dashboards
- Login/register modal, upload to market
- PocketBase API fully proxied, zero credentials in frontend
2026-07-26 19:52:00 +08:00
AskaEth 06042a93a2 chore: add project URL to about section 2026-07-26 19:12:29 +08:00
AskaEth 7e9211507b refactor: move record/playback to dedicated page, restore test mode in settings
- New '录制回放' page with sidebar nav entry
- Recording: start/stop/auto, file list with play/export/delete
- Playback: visual bar indicator, opens dashboards to see replayed data
- Settings: restored dev tools section with test mode toggle
2026-07-26 19:10:48 +08:00
AskaEth 3514612f7a feat: #8 i18n support (zh/en toggle) 2026-07-26 18:45:23 +08:00
AskaEth 71d039ba82 feat: #4 shift lights + #5 phone dash + #6 PWA support 2026-07-26 18:43:59 +08:00
AskaEth ea32fa841e feat: #3 tire temperature matrix dashboard (FL/FR/RL/RR heatmap) 2026-07-26 18:42:36 +08:00
AskaEth a2e7141338 feat: #2 G-force circle dashboard + .tsr export/import
- g_force dashboard: acceleration vector on 2G ring, lat/long G readout
- .tsr files export/import via API and UI
- Composite dashboard reverted (G-force is standalone)
2026-07-26 18:41:35 +08:00
AskaEth a9bd35baa8 feat: #1 telemetry record/playback with auto-race detection
- TelemetryRecorder: manual + auto mode (IsRaceOn edge detection)
- TelemetryPlayer: timed packet replay into _handle_packet pipeline
- .tsr format: [uint32 elapsed_ms][uint32 len][bytes]
- Settings UI: start/stop, auto toggle, recording list, play button
- API: /record/*, /playback/*, /recordings
2026-07-26 18:40:09 +08:00
AskaEth 4bba4b850d fix: settings nav uses onclick scroll instead of hash (avoid router conflict) 2026-07-26 18:37:03 +08:00
AskaEth e97d6ee36f fix: RPM unit label to 'rpm' matching displayed value 2026-07-26 18:34:08 +08:00
AskaEth 222b89e717 feat: settings sub-sidebar for section navigation 2026-07-26 18:33:11 +08:00
AskaEth b44241c666 feat: restart button in settings via atexit+execv 2026-07-26 18:32:16 +08:00
AskaEth 2daa4d48f1 feat: .tsb backup import/restore functionality 2026-07-26 18:31:04 +08:00
AskaEth 02ffa33298 style: Miuix switch for all checkboxes in settings (unified port, forward toggles) 2026-07-26 18:30:25 +08:00
AskaEth 1d702103f1 fix: suppress WebSocket keepalive timeout tracebacks from stderr 2026-07-26 18:28:45 +08:00
AskaEth 9f009a02f5 fix: single log file per startup - singleton handler shared across all modules 2026-07-26 18:27:24 +08:00
AskaEth cd2106dbf8 fix: auto-start test mode on boot if config says on; Miuix toggle switch style 2026-07-26 18:26:29 +08:00
AskaEth ff0abce5c0 fix: untrack old turbosu.log 2026-07-26 18:22:31 +08:00
AskaEth 5d5af6b6a7 feat: timestamped log files per startup, auto-cleanup to 20 max 2026-07-26 18:22:22 +08:00
AskaEth 16ada23d61 fix: timestamp overflow in test packet (uint32) - use & 0xFFFFFFFF 2026-07-26 18:21:17 +08:00
AskaEth 3b7edc4f14 fix: game plugin cache + test mode NullPointer crash on shutdown
- game_manager: add _scanned cache, _ensure_scanned() avoids re-scanning on every API call
- test_mode: fix AttributeError when stop_test_mode called before start
- listener: safe stop_test_mode with null check
2026-07-26 18:19:03 +08:00
AskaEth 90ab5f9c0b chore: increase log rotation to 20 backup files 2026-07-26 18:12:22 +08:00
AskaEth 69b73e8ce0 refactor: extract test data generator to server/test_mode.py
- TestDataGenerator class: builds 324-byte Forza packets, runs async loop at 30fps
- Simulated data passes through _handle_packet (parser -> WS -> forwarder)
- Listener delegates to test_mode.get_generator()
- Cleaner separation: listener.py no longer contains test logic
2026-07-26 18:07:19 +08:00
AskaEth 5690d792a5 fix: use struct.pack_into for safe 324-byte test packet construction 2026-07-26 17:59:22 +08:00
AskaEth 37d9f94505 fix: test mode forward proper 324-byte Forza-format packet to downstream devices 2026-07-26 17:57:59 +08:00
AskaEth 58ebb42f8c fix: settings.js syntax error causing blank webUI, rewrite clean 2026-07-26 17:54:44 +08:00
AskaEth b00e59d5bf feat: UI test mode + per-game port configuration
- Settings: UI test mode toggle generates simulated telemetry data (speed/RPM/gear/laps...)
- Simulated data broadcasts via WebSocket and forwards to downstream devices
- game_ports config: per-game independent port overrides (shown when unified port off)
- Settings UI: game port list with save button, hidden when unified port enabled
- Sidebar: switch game reads game_ports[gameId] fallback to default_port
2026-07-26 17:49:18 +08:00
AskaEth bbaef43cfd feat: #5 - live telemetry cards on home page (speed/RPM/gear on top) 2026-07-26 17:32:36 +08:00
AskaEth a08120c568 feat: 2,3,7,8,9 - search/sort, Docker, log viewer, .tsb backup
- Dashboard browser: search box + sort dropdown (name/category/author)
- Dockerfile + docker-compose.yml
- Debug page: log viewer button (/api/logs)
- Settings: .tsb full backup export (dashboards+scenes+config)
- F1 composite dashboard previously included
2026-07-26 17:32:09 +08:00
AskaEth 9091f2133d feat: 1,4,6 - scene canvas editor, systemd service, F1 composite dashboard
- Scene editor: drag-drop dashboards from sidebar to canvas, reposition, delete placements, save/load
- Systemd service: turbosu.service with frpc dependency, restart on failure
- F1 composite: speed/RPM/lap/gear/throttle/brake/fuel/DRS, 16:9, NEXT ART font
2026-07-26 17:30:19 +08:00
AskaEth e28be26ab3 fix: 7 bug fixes + port config with auto-restart
- HTTPException import added (was causing 500 on missing dashboard)
- game_manager.remove_plugin() restored (was AttributeError)
- api.js checks HTTP status codes, fails fast on errors
- sidebar game highlight only matches selected gameId
- scene resize handler uses stored canvas dims (was NaN)
- forwarder socket properly closed on listener stop
- telemetry port: use_unified_port toggle, off = per-game port, auto-restart on switch
2026-07-26 17:23:14 +08:00
AskaEth 41da7c03d5 fix: default gear N, defer initial draw until canvas sized 2026-07-26 13:37:29 +08:00
AskaEth a51cec79c1 fix: suppress noisy WS keepalive timeout tracebacks 2026-07-25 21:26:58 +08:00
AskaEth 7bafe73d6d fix: sidebar toggle always accessible when collapsed 2026-07-25 21:24:53 +08:00
AskaEth 5854b4cc57 fix: add debounce to theme toggle to prevent flash-back 2026-07-25 21:22:34 +08:00
AskaEth 7ca1053175 fix: reduce vw% for better scaling at small window sizes 2026-07-25 19:12:35 +08:00
AskaEth 4843c61841 fix: 8k mark label above gauge line instead of below 2026-07-25 19:10:05 +08:00
54 changed files with 3005 additions and 328 deletions
+2 -2
View File
@@ -34,8 +34,7 @@ Desktop.ini
.directory
# Logs
logs/*.log
logs/*.log.*
logs/turbosu_*.log*
# Runtime data
data/config.json
@@ -67,3 +66,4 @@ htmlcov/
# Node (if any)
node_modules/
package-lock.json
logs/turbosu.log
+21
View File
@@ -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"
]
}
]
}
+7
View File
@@ -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"]
+18 -14
View File
@@ -1,23 +1,27 @@
# TurboSu
# TurboSu · 极速中枢
赛车遥测仪表盘 — 基于 Web 的 B/S 架构外置仪表盘,支持多种赛车游戏遥测数据实时显示。
基于 Web 的 B/S 架构赛车遥测仪表盘,支持多种赛车游戏遥测数据实时显示。
社区市场在线分享仪表盘和场景。
🌐 [turbosu.yeij.top](https://turbosu.yeij.top)
![License](https://img.shields.io/badge/license-Apache%202.0-blue)
![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![Platform](https://img.shields.io/badge/platform-web%20%7C%20lan-brightgreen)
![Version](https://img.shields.io/badge/version-Alpha%20v0.1.0-orange)
## 功能特性
- **多游戏支持** — 插件化架构,内置 Forza Horizon 4/5、Forza Motorsport、Assetto Corsa/ACC、F1、iRacing
- **社区扩展** — 游戏插件可独立打包导入导出 (`.tsp`),方便社区贡献小众游戏支持
- **实时仪表盘** — 速度表、转速表、档位指示器、圈速计时器
- **独立仪表盘** — 每个仪表盘是独立文件夹 (`manifest.json` + `index.html`),启动自动扫描
- **场景模式** — 仪表盘布局组合,支持多比例画布切换
- **局域网共享** — 仪表盘/场景生成独立链接,手机/平板/笔记本均可全屏访问
- **跨端自适应** — 响应式布局:桌面侧边栏 / 平板折叠 / 手机底部导航
- **比例约束渲染** — 仪表盘可指定比例 (`auto`/`16:9`/`4:3`/`1:1`) 和渲染模式 (`contain`/`cover`/`fill`/`center`)
- **HyperOS 风格** — Miuix 设计语言,毛玻璃模糊效果,日间/夜间模式
- **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式,便于分享
- **多游戏支持** — 插件化架构,内置 Forza Horizon、Forza Motorsport、AC/ACC、F1、iRacing
- **8 款内置仪表盘** — 速度表、转速弧、档位指示器、圈速计时器、Forza 复合仪表、F1 复合仪表、G 力圆图、轮胎温度矩阵、换挡指示灯、手机竖屏仪表
- **场景编辑器** — 拖放式画布,自由组合多个仪表盘到同一画面
- **局域网共享** — 仪表盘/场景独立链接,手机/平板/笔记本均可全屏访问
- **比例约束渲染** — 仪表盘可指定比例和渲染模式
- **社区市场** — 在线分享仪表盘场景,登录即上传,管理员审核发布
- **录制回放** — 录制原始 UDP 流,离线回放调试仪表盘
- **数据转发** — 原始 UDP 包转发到下游物理外设
- **HyperOS 风格** — Miuix 设计语言,日间/夜间模式,中英文切换
- **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式
## 快速开始
@@ -29,10 +33,10 @@
### 运行
```bash
git clone ssh://git@git.yeij.top:2222/AskaEth/TurboSu.git
git clone https://git.yeij.top/AskaEth/TurboSu.git
cd TurboSu
# 一键启动 (自动创建 venv 并安装依赖)
# 一键启动
./run.sh
# 或手动启动
+42 -7
View File
@@ -5,7 +5,7 @@ import os
from contextlib import asynccontextmanager
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.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse
@@ -27,13 +27,29 @@ os.makedirs(BASE_DIR / "logs", exist_ok=True)
@asynccontextmanager
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(" TurboSu - Racing Telemetry Dashboard")
logger.info(" TurboSu - 极速中枢")
logger.info("=" * 50)
cfg = get_config()
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
await telemetry_listener.start()
logger.info("Telemetry listener auto-started")
if cfg.get("ui_test_mode"):
telemetry_listener.start_test_mode()
logger.info("Test mode auto-started (config)")
yield
telemetry_listener.stop()
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)
async def scene_render(request: Request, scene_id: str):
from models.scene import scene_manager
@@ -96,15 +131,15 @@ async def websocket_endpoint(ws: WebSocket):
await ws.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error("WS error: %s", e)
except Exception:
pass
finally:
ws_manager.disconnect(cid)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/favicon.ico")
async def favicon():
return FileResponse(STATIC_DIR / "img" / "logo.svg")
if __name__ == "__main__":
+4
View File
@@ -17,6 +17,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
"theme": "dark",
"sidebar_collapsed": False,
"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",
"server_host": "0.0.0.0",
"server_port": 9527,
+177
View File
@@ -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>
+14
View File
@@ -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"
}
}
+9 -8
View File
@@ -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-canvas{width:100%;height:100%}
#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-unit{font-size:min(6vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px}
#speed-val{font-size:min(12vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em}
#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-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%}
.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 .pf{height:100%;transition:width .06s linear}
#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="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="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 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>
@@ -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.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
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.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.fillText('\u00d71000 rpm',sx+160,sy-180);
ctx.fillText('rpm',sx+160,sy-180);
// redline
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 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>
</body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+90
View File
@@ -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>
+14
View File
@@ -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"
}
}
+86
View File
@@ -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>
+14
View File
@@ -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"
}
}
+67
View File
@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.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>
+14
View File
@@ -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"
}
}
+89
View File
@@ -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>
+14
View File
@@ -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"
}
}
+13
View File
@@ -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
+17
View File
@@ -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
+5
View File
@@ -45,6 +45,11 @@ class ForzaHorizonParser:
td.handbrake = struct.unpack_from("<B", data, 318)[0] / 255.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.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:
pass
+9
View File
@@ -35,6 +35,7 @@ class DashboardTheme:
updated_at: str = ""
is_builtin: bool = False
dir_path: str = ""
preview_ext: str = ""
def to_dict(self) -> dict[str, Any]:
return {
@@ -51,6 +52,7 @@ class DashboardTheme:
"created_at": self.created_at,
"updated_at": self.updated_at,
"is_builtin": self.is_builtin,
"preview_ext": self.preview_ext,
}
@classmethod
@@ -59,6 +61,12 @@ class DashboardTheme:
with open(manifest_path, "r", encoding="utf-8") as f:
data = json.load(f)
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(
id=data.get("id", manifest_path.parent.name),
name=data.get("name", manifest_path.parent.name),
@@ -74,6 +82,7 @@ class DashboardTheme:
updated_at=data.get("updated_at", ""),
is_builtin=is_builtin,
dir_path=str(manifest_path.parent),
preview_ext=preview_ext,
)
except Exception as e:
logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e)
+50 -20
View File
@@ -84,34 +84,51 @@ class SceneManager:
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
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:
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
scene = Scene.from_dict(data)
if game_id is None or scene.game_id == game_id:
scenes.append(scene.to_dict())
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:
logger.error("Failed to load scene %s: %s", f.name, e)
logger.error("Failed to load scene %s: %s", item.name, e)
return scenes
def get(self, scene_id: str) -> dict[str, Any] | None:
filepath = SCENES_DIR / f"{scene_id}.json"
if not filepath.exists():
item = SCENES_DIR / scene_id
f = item / "manifest.json"
if not f.exists():
return None
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return Scene.from_dict(data).to_dict()
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
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:
logger.error("Failed to read scene %s: %s", scene_id, e)
return None
def save(self, scene: Scene) -> bool:
scene.updated_at = datetime.now().isoformat()
filepath = SCENES_DIR / f"{scene.id}.json"
item = SCENES_DIR / scene.id
item.mkdir(parents=True, exist_ok=True)
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)
logger.info("Scene saved: %s", scene.id)
return True
@@ -120,32 +137,45 @@ class SceneManager:
return False
def delete(self, scene_id: str) -> bool:
filepath = SCENES_DIR / f"{scene_id}.json"
if filepath.exists():
filepath.unlink()
import shutil
item = SCENES_DIR / scene_id
if item.exists():
shutil.rmtree(item)
logger.info("Scene deleted: %s", scene_id)
return True
return False
def export_scene_zip(self, scene_id: str) -> bytes | None:
filepath = SCENES_DIR / f"{scene_id}.json"
if not filepath.exists():
item = SCENES_DIR / scene_id
if not item.exists():
return None
buf = io.BytesIO()
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)
return buf.getvalue()
def import_scene_zip(self, zip_data: bytes) -> bool:
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if "scene.json" not in zf.namelist():
logger.error("No scene.json found in zip")
if "manifest.json" not in zf.namelist():
logger.error("No manifest.json found in zip")
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)
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:
logger.error("Failed to import scene zip: %s", e)
return False
+1
View File
@@ -4,3 +4,4 @@ websockets==14.1
aiofiles==24.1.0
jinja2==3.1.4
python-multipart==0.0.18
httpx==0.28.1
+506
View File
@@ -72,6 +72,18 @@ async def api_stop_telemetry():
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")
async def api_latest_telemetry():
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())}
# ---- 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 ----
@router.get("/forward")
async def api_list_forward_targets():
@@ -329,3 +422,416 @@ async def api_update_forward_targets(data: dict[str, Any]):
save_config(cfg)
telemetry_listener.forwarder.reload_targets()
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
View File
@@ -68,11 +68,11 @@ class GamePluginManager:
USER_DIR.mkdir(parents=True, exist_ok=True)
self._plugins: dict[str, GamePlugin] = {}
self._parser_cache: dict[str, Any] = {}
self._scanned = False
self._discover()
def _discover(self):
self._plugins.clear()
for d in [BUILTIN_DIR, USER_DIR]:
if not d.exists():
continue
@@ -84,14 +84,18 @@ class GamePluginManager:
gp = GamePlugin.from_manifest(manifest, is_builtin)
if 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]]:
self._discover()
self._ensure_scanned()
return [p.to_dict() for p in self._plugins.values()]
def get(self, plugin_id: str) -> GamePlugin | None:
self._discover()
self._ensure_scanned()
return self._plugins.get(plugin_id)
def get_parser(self, plugin_id: str) -> Any | None:
@@ -130,6 +134,20 @@ class GamePluginManager:
self._discover()
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:
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
+134
View File
@@ -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)]
+25
View File
@@ -112,11 +112,32 @@ class TelemetryListener:
def stop(self):
self._running = False
self.stop_test_mode()
if self._transport:
self._transport.close()
self._transport = None
if self.forwarder._sock:
self.forwarder._sock.close()
self.forwarder._sock = None
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:
if key not in self._parser_cache:
cls = PARSER_MAP.get(key)
@@ -130,6 +151,10 @@ class TelemetryListener:
self.forwarder.forward(data)
from server.api import _recorder
if _recorder and _recorder.is_recording:
_recorder.write_packet(data)
td = None
if self._parser:
td = self._parser.parse(data, addr)
+119
View File
@@ -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
View File
@@ -27,10 +27,17 @@
}
.sidebar.collapsed .nav-label,
.sidebar.collapsed .sidebar-title,
.sidebar.collapsed .sidebar-logo,
.sidebar.collapsed .collapse-arrow {
opacity: 0;
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 {
display: flex;
@@ -493,6 +500,7 @@
border-radius: var(--radius-sm);
cursor: move;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
@@ -500,7 +508,24 @@
background: var(--accent-container);
min-width: 80px;
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-container {
@@ -683,6 +708,16 @@ code {
.sidebar {
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 {
@@ -693,6 +728,9 @@ code {
height: 48px;
padding: 0 12px;
}
#mobile-menu-btn {
display: flex !important;
}
.topbar-logo-title.hidden {
opacity: 1;
pointer-events: auto;
@@ -861,3 +899,11 @@ code {
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; }
+2
View File
@@ -126,6 +126,8 @@ html, body {
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
transition: background var(--transition-normal), color var(--transition-normal);
}
+6
View File
@@ -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

+6
View File
@@ -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
View File
@@ -1,33 +1,24 @@
const API = {
_base: '/api',
async get(path) {
const res = await fetch(`${this._base}${path}`);
async _req(method, path, body) {
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();
},
async post(path, data) {
const res = await fetch(`${this._base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
async put(path, data) {
const res = await fetch(`${this._base}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
async del(path) {
const res = await fetch(`${this._base}${path}`, { method: 'DELETE' });
return res.json();
},
async get(path) { return this._req('GET', path); },
async post(path, data) { return this._req('POST', path, data); },
async put(path, data) { return this._req('PUT', path, data); },
async del(path) { return this._req('DELETE', path); },
async getStatus() { return this.get('/status'); },
async getConfig() { return this.get('/config'); },
+4
View File
@@ -1,4 +1,5 @@
(function () {
Lang.init();
Theme.init();
WS.init();
Sidebar.init();
@@ -7,10 +8,13 @@
PageDashboard.init();
PageScene.init();
PageDebug.init();
PageRecord.init();
PageMarket.init();
PageSettings.init();
Router.init();
document.getElementById('theme-toggle').addEventListener('click', () => Theme.toggle());
document.getElementById('lang-toggle').addEventListener('click', () => Lang.toggle());
console.log('%cTurboSu %cReady',
'color:#667eea;font-size:16px;font-weight:bold;',
+10 -2
View File
@@ -3,6 +3,7 @@ const Sidebar = {
_gameListEl: null,
_gameToggleEl: null,
_gameLabelEl: null,
_gameData: [],
init() {
this._el = document.getElementById('sidebar');
@@ -44,6 +45,7 @@ const Sidebar = {
async _loadGames() {
const games = await API.getGames();
this._gameData = games;
this._gameListEl.innerHTML = '';
if (!Array.isArray(games) || games.length === 0) {
@@ -80,11 +82,17 @@ const Sidebar = {
async _selectGame(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();
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const items = this._gameListEl.querySelectorAll('.nav-item');
items.forEach(item => {
if (item.textContent.trim()) item.classList.add('active');
items.forEach((item, idx) => {
if (this._gameData[idx] && this._gameData[idx].id === gameId) item.classList.add('active');
});
const games = await API.getGames();
this._updateGameLabel(gameId, games);
+1 -1
View File
@@ -21,7 +21,7 @@ const Topbar = {
this._statusEl.className = `status-indicator ${wsStatus}`;
this._statusEl.querySelector('.status-text').textContent =
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接';
} catch (e) {}
} catch (e) { console.error(e); }
}
};
+194 -40
View File
@@ -2,9 +2,12 @@ const PageDashboard = {
_el: null,
_currentCategory: 'all',
_selectedGameId: null,
_marketMode: false,
_marketToken: null,
init() {
this._el = document.getElementById('page-dashboard');
this._marketToken = localStorage.getItem('turbosu-market-token');
},
async render() {
@@ -43,30 +46,48 @@ const PageDashboard = {
async _loadThemes() {
const gridEl = document.getElementById('dash-theme-grid');
if (!gridEl) return;
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
if (this._marketMode) {
await this._loadMarket(gridEl);
return;
}
try {
let themes = await API.getDashboards(this._currentCategory);
if (!themes || themes.length === 0) {
gridEl.innerHTML = this._emptyHtml();
return;
}
if (this._selectedGameId) {
themes = themes.filter(t => this._isGameSupported(t));
}
if (themes.length === 0) {
gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘');
return;
}
if (!themes || themes.length === 0) { gridEl.innerHTML = this._emptyHtml(); return; }
if (this._selectedGameId) { themes = themes.filter(t => this._isGameSupported(t)); }
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';
themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘'); return; }
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
} catch (e) {
console.error('Failed to load themes:', e);
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
}
} catch (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) {
@@ -104,35 +125,142 @@ const PageDashboard = {
const card = e.target.closest('.theme-card');
if (!card) return;
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) => {
const card = e.target.closest('.theme-card');
if (!card) return;
if (!card || !card.dataset.themeId) return;
e.preventDefault();
const themeId = card.dataset.themeId;
this._exportDashboard(themeId);
this._exportDashboard(card.dataset.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', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.tsd';
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 formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
else Toast.show('导入失败', 'error');
} catch (err) { Toast.show('导入失败', 'error'); }
};
input.click();
const file = e.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
const data = await res.json();
if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); }
else Toast.show('导入失败', 'error');
}; 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) {
@@ -161,9 +289,11 @@ const PageDashboard = {
const isAll = supported === 'all';
const compatLabel = isAll ? '全' : '限';
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 `
<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-name">${theme.name || 'Unnamed'}</div>
<div class="theme-card-meta">
@@ -178,16 +308,40 @@ const PageDashboard = {
</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() {
return `
<div class="dashboard-layout">
<div class="dashboard-sub-sidebar">
<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>
</div>
</div>
<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>
</div>`;
+12
View File
@@ -33,6 +33,17 @@ const PageDebug = {
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
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) {
@@ -97,6 +108,7 @@ const PageDebug = {
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
</label>
<button id="debug-show-log" class="btn btn-sm btn-secondary">📄 日志</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
<div style="overflow-y:auto;">
+5 -5
View File
@@ -70,17 +70,17 @@ const PageHome = {
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p>
</div>
<div class="status-grid animated" style="animation-delay:0.1s">
${this._cardHtml('card-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-telemetry', '遥测监听', '未启动', 'warning')}
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
</div>
<div class="status-grid animated" style="animation-delay:0.2s">
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')}
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')}
${this._cardHtml('card-gear', '当前档位', 'N', 'primary')}
</div>`;
},
+497
View File
@@ -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;
+89
View File
@@ -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;
+205 -157
View File
@@ -3,7 +3,14 @@ const PageScene = {
_currentGameId: null,
_currentGameName: '',
_editorSceneId: null,
_editorData: null,
_editorCanvasIdx: 0,
_placements: [],
_dashboards: [],
_dragItem: null,
_dragOffsetX: 0,
_dragOffsetY: 0,
_resizeItem: null,
_resizeDir: '',
init() {
this._el = document.getElementById('page-scene');
@@ -12,208 +19,249 @@ const PageScene = {
async render() {
const cfg = await API.getConfig();
this._currentGameId = cfg.selected_game_id;
if (this._currentGameId) {
const games = await API.getGames();
const game = games.find(g => g.id === this._currentGameId);
this._currentGameName = game ? game.name : this._currentGameId;
this._dashboards = await API.getDashboards('all');
}
this._el.innerHTML = this._template();
await this._loadScenes();
this._showSceneList();
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');
if (!grid) return;
try {
const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) {
grid.innerHTML = `
<div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<h4>暂无场景</h4>
<p>点击"新建场景"创建你的第一个场景布局</p>
</div>`;
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>暂无场景</h4><p>点击"新建场景"创建你的场景</p></div>`;
return;
}
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) {
console.error('Failed to load scenes:', e);
}
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
},
_bindEvents() {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor());
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 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) {
async _openEditor(sceneId) {
this._editorSceneId = sceneId;
this._editorCanvasIdx = 0;
this._placements = [];
let scene = null;
if (sceneId) {
scene = await API.getScene(sceneId);
if (!scene) return;
}
this._editorSceneId = sceneId;
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = this._editorTemplate(scene);
document.body.appendChild(modal);
modal.querySelector('.modal-overlay, .modal-actions .btn-secondary')
?.addEventListener('click', (e) => {
if (e.target === modal || e.target.matches('.btn-secondary')) {
modal.remove();
}
});
modal.querySelector('.btn-primary')?.addEventListener('click', async () => {
const name = modal.querySelector('#edit-scene-name').value || 'New Scene';
const desc = modal.querySelector('#edit-scene-desc').value || '';
const gameId = this._currentGameId || '';
const data = { name, description: desc, game_id: gameId };
if (this._editorSceneId) {
await API.updateScene(this._editorSceneId, data);
} else {
await API.createScene(data);
if (scene && scene.canvases && scene.canvases[0]) {
this._placements = scene.canvases[0].placements || [];
}
modal.remove();
Toast.show('场景保存成功', 'success');
this.render();
}
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);
},
_renderCanvasEditor(scene) {
const nameInput = document.getElementById('edit-scene-name');
if (nameInput && scene) nameInput.value = scene.name || '';
this._renderDashboardList();
this._renderCanvas();
},
_renderDashboardList() {
const list = document.getElementById('editor-dash-list');
if (!list) return;
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 };
});
});
},
async _renderScene(sceneId) {
_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();
}
});
});
canvas.addEventListener('dragover', (e) => e.preventDefault());
canvas.addEventListener('drop', (e) => {
e.preventDefault();
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();
}
});
},
_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) {
await API.updateScene(this._editorSceneId, data);
} else {
const created = await API.createScene(data);
this._editorSceneId = created.id;
}
Toast.show('场景保存成功', 'success');
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(); }
});
},
_renderScene(sceneId) {
const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => {
Toast.show('场景链接已复制,可在局域网设备访问', 'success');
}).catch(() => {});
},
async _deleteScene(sceneId) {
if (!confirm('确定删除此场景?')) return;
await API.deleteScene(sceneId);
Toast.show('场景已删除', 'info');
this._loadScenes();
},
_exportScene(sceneId) {
const a = document.createElement('a');
a.href = `/api/scenes/${sceneId}/export`;
a.download = `${sceneId}.tss`;
a.click();
Toast.show('正在下载 .tss 文件...', 'info');
navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {});
},
_sceneCardHtml(scene) {
const canvasCount = (scene.canvases || []).length;
return `
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
<div class="scene-card-name">${scene.name}</div>
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div>
<div class="scene-card-meta">
<span>${canvasCount} 画布</span>
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span>
</div>
<div class="scene-canvas-list">
${(scene.canvases || []).map(c => `<span class="scene-canvas-badge">${c.label || c.width + 'x' + c.height}</span>`).join('')}
</div>
<div style="margin-top:12px;display:flex;gap:6px;">
const preview = scene.preview_ext ? `/scene/${scene.id}/preview.${scene.preview_ext}` : '';
return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || '未关联游戏'}</div>
<div class="scene-card-meta"><span>${canvasCount} 画布</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
<div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
<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="export">导出</button>
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
</div>
</div>`;
},
_editorTemplate(scene) {
const name = scene ? scene.name : '';
const desc = scene ? scene.description : '';
return `
<div class="modal">
<h3>${scene ? '编辑场景' : '新建场景'}</h3>
<div class="form-group">
<label>场景名称</label>
<input id="edit-scene-name" type="text" value="${name}" placeholder="输入场景名称">
</div>
<div class="form-group">
<label>描述</label>
<textarea id="edit-scene-desc" placeholder="场景描述(可选)">${desc}</textarea>
</div>
<p style="font-size:12px;color:var(--text-tertiary);margin-top:8px;">
提示保存后可在场景编辑器中添加仪表盘调整布局和画布比例
</p>
<div class="modal-actions">
<button class="btn btn-secondary">取消</button>
<button class="btn btn-primary">保存</button>
</div>
</div>`;
</div></div>`;
},
_template() {
return `
<div class="scene-header animated">
<div class="scene-header animated" id="scene-list-container">
<div>
<h2>场景管理</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;">
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}</p>
</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-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>
+ 新建场景
</button>
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>+ 新建场景</button>
</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
View File
@@ -11,6 +11,12 @@ const PageSettings = {
const forwards = await this._getForwards();
this._el.innerHTML = this._template(cfg, games, forwards);
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() {
@@ -25,7 +31,13 @@ const PageSettings = {
_bindEvents() {
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
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 () => {
@@ -80,6 +92,89 @@ const PageSettings = {
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
}; 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() {
@@ -87,7 +182,7 @@ const PageSettings = {
const row = document.createElement('div');
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.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);
},
@@ -104,9 +199,34 @@ const PageSettings = {
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) {
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('');
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 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) {
@@ -115,19 +235,38 @@ const PageSettings = {
},
_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>';
}
};
+2
View File
@@ -4,6 +4,8 @@ const Router = {
dashboard: PageDashboard,
scene: PageScene,
debug: PageDebug,
record: PageRecord,
market: PageMarket,
settings: PageSettings,
},
_currentPage: 'home',
+67
View File
@@ -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;
+4
View File
@@ -1,5 +1,6 @@
const Theme = {
_current: 'dark',
_toggling: false,
init() {
this._current = localStorage.getItem('turbosu-theme') || 'dark';
@@ -7,8 +8,11 @@ const Theme = {
},
toggle() {
if (this._toggling) return;
this._toggling = true;
this._current = this._current === 'dark' ? 'light' : 'dark';
this.apply();
setTimeout(() => { this._toggling = false; }, 200);
},
apply() {
+17 -6
View File
@@ -13,14 +13,25 @@ const Toast = {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
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);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, duration);
if (duration > 0) {
setTimeout(() => {
if (toast.parentNode) {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}
}, duration);
}
}
};
+1 -1
View File
@@ -36,7 +36,7 @@ const WS = {
if (msg.type === 'telemetry') {
this._emit('telemetry', msg.data);
}
} catch (e) {}
} catch (e) { console.error(e); }
};
this._ws.onclose = () => {
+12
View File
@@ -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"}
]
}
+3
View File
@@ -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
View File
@@ -3,20 +3,19 @@
<head>
<meta charset="UTF-8">
<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/main.css">
</head>
<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">
<aside id="sidebar" class="sidebar expanded">
<div class="sidebar-header">
<div class="sidebar-logo">
<svg viewBox="0 0 48 48" width="40" height="40">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">S</text>
</svg>
<img src="/static/img/logo.svg" alt="TurboSu" width="40" height="40">
</div>
<div class="sidebar-title">TurboSu</div>
<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>
<span class="nav-label">数据测试</span>
</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">
<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>
@@ -66,11 +73,11 @@
<main class="main-content">
<header id="topbar" class="topbar">
<div class="topbar-left">
<div id="topbar-logo-title" class="topbar-logo-title hidden">
<svg viewBox="0 0 48 48" width="28" height="28">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
</svg>
<button id="mobile-menu-btn" class="icon-btn" style="display:none" onclick="toggleMobileMenu()">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/></svg>
</button>
<div id="topbar-logo-title" class="topbar-logo-title hidden" style="cursor:pointer" onclick="toggleMobileMenu()">
<img src="/static/img/logo.svg" alt="TurboSu" width="28" height="28">
<span class="topbar-title-text">TurboSu</span>
</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-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 id="lang-toggle" class="icon-btn" title="Language" style="font-size:13px;font-weight:600"></button>
</div>
</header>
@@ -95,6 +103,10 @@
</div>
<div id="page-debug" class="page">
</div>
<div id="page-record" class="page">
</div>
<div id="page-market" class="page">
</div>
<div id="page-settings" class="page">
</div>
</div>
@@ -103,6 +115,7 @@
<script src="/static/js/utils/theme.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/ws.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/scene.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/router.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>
</html>
-2
View File
@@ -124,8 +124,6 @@
}
window.addEventListener('resize', () => {
const cw = parseInt(sceneCanvas.style.width) / (parseInt(sceneCanvas.style.width) / 1920) || 1920;
const ch = parseInt(sceneCanvas.style.height) / (parseInt(sceneCanvas.style.height) / 1080) || 1080;
const vw = window.innerWidth;
const vh = window.innerHeight;
const scale = Math.min(vw / cw, vh / ch);
+39 -12
View File
@@ -1,36 +1,63 @@
import logging
import os
import sys
import uuid
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / "turbosu.log"
LOG_FORMAT = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
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:
_init_handlers()
logger = logging.getLogger(name)
logger.setLevel(level)
logger.propagate = False
if not logger.handlers:
fh = RotatingFileHandler(
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(LOG_FORMAT)
logger.addHandler(fh)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
ch.setFormatter(LOG_FORMAT)
logger.addHandler(ch)
logger.addHandler(_root_handler)
logger.addHandler(_console_handler)
return logger