139 Commits

Author SHA1 Message Date
AskaEth b976d83cf2 fix: i18n sidebar labels in static HTML 2026-07-27 21:49:31 +08:00
AskaEth de58a1c42f feat: full i18n support - all pages use Lang.t() for zh/en 2026-07-27 20:31:41 +08:00
AskaEth 860bc015e8 fix: lang toggle button updates on init 2026-07-27 19:46:18 +08:00
AskaEth 5cea0f9851 feat: click status indicator to force reconnect 2026-07-27 19:40:50 +08:00
AskaEth 5bc2c18f06 fix: WS reconnect on foreground, visibility change listener 2026-07-27 19:15:32 +08:00
AskaEth 4becc26e45 docs: add protocol/mode fields to plugin development guide 2026-07-27 19:13:00 +08:00
AskaEth e4fe499bef refine: TCP client mode handled by plugin itself 2026-07-27 19:12:19 +08:00
AskaEth 759d35bc93 feat: TCP server/client mode config, protocol display in settings 2026-07-27 19:09:53 +08:00
AskaEth f715cb85e6 feat: TCP listener + protocol field in manifest, dual UDP/TCP support 2026-07-27 19:04:16 +08:00
AskaEth 1ebea5eb1a chore: mark iRacing parser as stub (shared memory, not UDP) 2026-07-27 19:00:28 +08:00
AskaEth 36755cc78a fix: F1 24 parser matches official EA SPORTS UDP spec with packet types 2026-07-27 18:56:46 +08:00
AskaEth 5630d0e332 docs: link F1/ACC/iRacing format references 2026-07-27 18:46:21 +08:00
AskaEth 29d51ad59e docs: add iRacing telemetry data format reference 2026-07-27 18:46:01 +08:00
AskaEth 5bb4a82ae0 docs: add ACC UDP data format reference 2026-07-27 18:41:56 +08:00
AskaEth 2ca8dc53f4 docs: add F1 24 UDP data format reference 2026-07-27 18:38:16 +08:00
AskaEth 9cb8c12c89 feat: serve any file from dashboard folder (CSS/JS/images) 2026-07-27 18:23:26 +08:00
AskaEth fb36eba07d docs: remove AI-generated conversational text from FH format doc 2026-07-27 17:59:46 +08:00
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
62 changed files with 5099 additions and 448 deletions
+2 -2
View File
@@ -34,8 +34,7 @@ Desktop.ini
.directory .directory
# Logs # Logs
logs/*.log logs/turbosu_*.log*
logs/*.log.*
# Runtime data # Runtime data
data/config.json data/config.json
@@ -67,3 +66,4 @@ htmlcov/
# Node (if any) # Node (if any)
node_modules/ node_modules/
package-lock.json package-lock.json
logs/turbosu.log
+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"]
+22 -15
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) ![License](https://img.shields.io/badge/license-Apache%202.0-blue)
![Python](https://img.shields.io/badge/python-3.10%2B-blue) ![Python](https://img.shields.io/badge/python-3.10%2B-blue)
![Platform](https://img.shields.io/badge/platform-web%20%7C%20lan-brightgreen) ![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 - **多游戏支持** — 插件化架构,内置 Forza Horizon、Forza Motorsport、AC/ACC、F1、iRacing
- **社区扩展** — 游戏插件可独立打包导入导出 (`.tsp`),方便社区贡献小众游戏支持 - **8 款内置仪表盘** — 速度表、转速弧、档位指示器、圈速计时器、Forza 复合仪表、F1 复合仪表、G 力圆图、轮胎温度矩阵、换挡指示灯、手机竖屏仪表
- **实时仪表盘** — 速度表、转速表、档位指示器、圈速计时器 - **场景编辑器** — 拖放式画布,自由组合多个仪表盘到同一画面
- **独立仪表盘** — 每个仪表盘是独立文件夹 (`manifest.json` + `index.html`),启动自动扫描 - **局域网共享** — 仪表盘/场景独立链接,手机/平板/笔记本均可全屏访问
- **场景模式** — 仪表盘布局组合,支持多比例画布切换 - **比例约束渲染** — 仪表盘可指定比例和渲染模式
- **局域网共享** — 仪表盘/场景生成独立链接,手机/平板/笔记本均可全屏访问 - **社区市场** — 在线分享仪表盘场景,登录即上传,管理员审核发布
- **跨端自适应** — 响应式布局:桌面侧边栏 / 平板折叠 / 手机底部导航 - **录制回放** — 录制原始 UDP 流,离线回放调试仪表盘
- **比例约束渲染** — 仪表盘可指定比例 (`auto`/`16:9`/`4:3`/`1:1`) 和渲染模式 (`contain`/`cover`/`fill`/`center`) - **数据转发** — 原始 UDP 包转发到下游物理外设
- **HyperOS 风格** — Miuix 设计语言,毛玻璃模糊效果,日间/夜间模式 - **HyperOS 风格** — Miuix 设计语言,日间/夜间模式,中英文切换
- **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式,便于分享 - **社区分发** — 仪表盘 `.tsd`、场景 `.tss`、插件 `.tsp` — zip 格式
## 快速开始 ## 快速开始
@@ -29,10 +33,10 @@
### 运行 ### 运行
```bash ```bash
git clone ssh://git@git.yeij.top:2222/AskaEth/TurboSu.git git clone https://git.yeij.top/AskaEth/TurboSu.git
cd TurboSu cd TurboSu
# 一键启动 (自动创建 venv 并安装依赖) # 一键启动
./run.sh ./run.sh
# 或手动启动 # 或手动启动
@@ -82,7 +86,10 @@ python app.py
- [仪表盘开发指南](docs/DASHBOARD_DEV.md) — 创建自定义仪表盘主题 (.tsd) - [仪表盘开发指南](docs/DASHBOARD_DEV.md) — 创建自定义仪表盘主题 (.tsd)
- [游戏插件兼容开发指南](docs/PLUGIN_DEV.md) — 为任意赛车游戏编写遥测解析器 (.tsp) - [游戏插件兼容开发指南](docs/PLUGIN_DEV.md) — 为任意赛车游戏编写遥测解析器 (.tsp)
- [Forza 数据包格式参考](docs/FH_DATA_FORMAT.md) — 官方 UDP 324 字节完整字段定义 - [Forza 数据包格式参考](docs/FH_DATA_FORMAT.md) — Forza Horizon UDP 324 字节完整定义
- [F1 24 数据包格式参考](docs/F1_DATA_FORMAT.md) — F1 24 UDP 遥测规范
- [ACC 数据包格式参考](docs/ACC_DATA_FORMAT.md) — Assetto Corsa Competizione 广播 API
- [iRacing 遥测格式参考](docs/IRACING_DATA_FORMAT.md) — iRacing IRSDK 遥测变量
## 项目结构 ## 项目结构
+52 -7
View File
@@ -5,7 +5,7 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse
@@ -27,13 +27,29 @@ os.makedirs(BASE_DIR / "logs", exist_ok=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
import asyncio
import warnings
from websockets.exceptions import ConnectionClosedError
def _handle_async_exception(loop, ctx):
exc = ctx.get("exception")
if isinstance(exc, ConnectionClosedError):
return
loop.default_exception_handler(ctx)
asyncio.get_event_loop().set_exception_handler(_handle_async_exception)
warnings.filterwarnings("ignore", message=".*ping timeout.*")
logger.info("=" * 50) logger.info("=" * 50)
logger.info(" TurboSu - Racing Telemetry Dashboard") logger.info(" TurboSu - 极速中枢")
logger.info("=" * 50) logger.info("=" * 50)
cfg = get_config() cfg = get_config()
logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527)) logger.info("Server config: %s:%d", cfg.get("server_host", "0.0.0.0"), cfg.get("server_port", 9527))
await telemetry_listener.start() await telemetry_listener.start()
logger.info("Telemetry listener auto-started") logger.info("Telemetry listener auto-started")
if cfg.get("ui_test_mode"):
telemetry_listener.start_test_mode()
logger.info("Test mode auto-started (config)")
yield yield
telemetry_listener.stop() telemetry_listener.stop()
logger.info("TurboSu shutdown complete") logger.info("TurboSu shutdown complete")
@@ -70,6 +86,35 @@ async def dashboard_render(request: Request, theme_id: str):
""") """)
@app.get("/dashboard/{theme_id}/preview.{ext}")
async def dashboard_preview(theme_id: str, ext: str):
from models.dashboard import BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
path = base / theme_id / f"preview.{ext}"
if path.exists():
return FileResponse(path)
raise HTTPException(404)
@app.get("/dashboard/{theme_id}/{path:path}")
async def dashboard_static(theme_id: str, path: str):
from models.dashboard import BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
filepath = base / theme_id / path
if filepath.exists() and filepath.is_file():
return FileResponse(filepath)
raise HTTPException(404)
@app.get("/scene/{scene_id}/preview.{ext}")
async def scene_preview(scene_id: str, ext: str):
from models.scene import SCENES_DIR
path = SCENES_DIR / scene_id / f"preview.{ext}"
if path.exists():
return FileResponse(path)
raise HTTPException(404)
@app.get("/scene/{scene_id}", response_class=HTMLResponse) @app.get("/scene/{scene_id}", response_class=HTMLResponse)
async def scene_render(request: Request, scene_id: str): async def scene_render(request: Request, scene_id: str):
from models.scene import scene_manager from models.scene import scene_manager
@@ -96,15 +141,15 @@ async def websocket_endpoint(ws: WebSocket):
await ws.send_json({"type": "pong"}) await ws.send_json({"type": "pong"})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception as e: except Exception:
logger.error("WS error: %s", e) pass
finally: finally:
ws_manager.disconnect(cid) ws_manager.disconnect(cid)
@app.get("/health") @app.get("/favicon.ico")
async def health(): async def favicon():
return {"status": "ok"} return FileResponse(STATIC_DIR / "img" / "logo.svg")
if __name__ == "__main__": if __name__ == "__main__":
+5
View File
@@ -17,6 +17,11 @@ DEFAULT_CONFIG: dict[str, Any] = {
"theme": "dark", "theme": "dark",
"sidebar_collapsed": False, "sidebar_collapsed": False,
"telemetry_port": 20777, "telemetry_port": 20777,
"tcp_port": 20778,
"use_unified_port": True,
"game_ports": {},
"ui_test_mode": False,
"market_url": "https://turbosu.yeij.top",
"telemetry_host": "0.0.0.0", "telemetry_host": "0.0.0.0",
"server_host": "0.0.0.0", "server_host": "0.0.0.0",
"server_port": 9527, "server_port": 9527,
+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-area{position:absolute;left:2%;top:4%;width:62%;height:82%}
#rpm-canvas{width:100%;height:100%} #rpm-canvas{width:100%;height:100%}
#speed-area{position:absolute;left:4%;bottom:10%;width:60%;text-align:center} #speed-area{position:absolute;left:4%;bottom:10%;width:60%;text-align:center}
#speed-val{font-size:min(24vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em} #speed-val{font-size:min(12vw,240px);font-weight:200;line-height:1;letter-spacing:-.02em}
#speed-unit{font-size:min(6vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px} #speed-unit{font-size:min(3vw,44px);color:rgba(255,255,255,.2);letter-spacing:.25em;margin-top:4px}
#gear-area{position:absolute;right:12%;top:50%;transform:translateY(-55%)} #gear-area{position:absolute;right:12%;top:50%;transform:translateY(-55%)}
#gear-box{width:min(24vw,200px);height:min(24vw,200px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center} #gear-box{width:min(24vw,200px);height:min(24vw,200px);border-radius:50%;border:1px solid rgba(255,255,255,.1);display:flex;align-items:center;justify-content:center}
#gear-box span{font-size:min(96vw,800px);font-weight:200;transition:.1s} #gear-box span{font-size:min(48vw,800px);font-weight:200;transition:.1s}
#pedals{position:absolute;right:4%;top:6%;width:22%} #pedals{position:absolute;right:4%;top:6%;width:22%}
.pedal{margin-bottom:22px} .pedal{margin-bottom:22px}
.pedal .pl{font-size:min(9.6vw,68px);color:rgba(255,255,255,.25);margin-bottom:8px;letter-spacing:.15em} .pedal .pl{font-size:min(4.8vw,68px);color:rgba(255,255,255,.25);margin-bottom:8px;letter-spacing:.15em}
.pedal .pb{height:24px;background:rgba(255,255,255,.06);overflow:hidden} .pedal .pb{height:24px;background:rgba(255,255,255,.06);overflow:hidden}
.pedal .pf{height:100%;transition:width .06s linear} .pedal .pf{height:100%;transition:width .06s linear}
#tf{background:#fff}#bf{background:rgba(255,255,255,.4)} #tf{background:#fff}#bf{background:rgba(255,255,255,.4)}
@@ -41,7 +41,7 @@ html,body{width:100%;height:100%;overflow:hidden;background:#060a16;color:#fff;
<div id="root"><div id="box" class="contain"><div id="content"> <div id="root"><div id="box" class="contain"><div id="content">
<div id="rpm-area"><canvas id="rpm-canvas"></canvas></div> <div id="rpm-area"><canvas id="rpm-canvas"></canvas></div>
<div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div> <div id="speed-area"><div id="speed-val">0</div><div id="speed-unit">KM/H</div></div>
<div id="gear-area"><div id="gear-box"><span id="gear-val">1</span></div></div> <div id="gear-area"><div id="gear-box"><span id="gear-val">N</span></div></div>
<div id="pedals"> <div id="pedals">
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div> <div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div> <div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
@@ -145,7 +145,7 @@ function draw(cur,max){
ctx.beginPath();ctx.moveTo(p.x,ey-gw/2-4);ctx.lineTo(p.x,ey+gw/2+4); ctx.beginPath();ctx.moveTo(p.x,ey-gw/2-4);ctx.lineTo(p.x,ey+gw/2+4);
ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)'; ctx.strokeStyle=r>=rl?'rgba(255,255,255,.15)':'rgba(255,255,255,.08)';
ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke(); ctx.lineWidth=r%4000===0?2.5:1;ctx.stroke();
if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),p.x,ey+gw/2+22)} if(r%4000===0){ctx.fillStyle='rgba(255,255,255,.22)';ctx.font=`${Math.max(26,W/19)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';ctx.fillText(Math.round(r/1000),p.x,ey-gw/2-10)}
} }
} }
@@ -153,7 +153,7 @@ function draw(cur,max){
ctx.fillStyle='#fff';ctx.font=`200 ${Math.max(128,W/4.5)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center'; ctx.fillStyle='#fff';ctx.font=`200 ${Math.max(128,W/4.5)|0}px 'NEXT ART',sans-serif`;ctx.textAlign='center';
ctx.fillText(Math.round(cur).toLocaleString(),sx+160,sy-260); ctx.fillText(Math.round(cur).toLocaleString(),sx+160,sy-260);
ctx.fillStyle='rgba(255,255,255,.12)';ctx.font=`${Math.max(32,W/17)|0}px 'NEXT ART',sans-serif`; ctx.fillStyle='rgba(255,255,255,.12)';ctx.font=`${Math.max(32,W/17)|0}px 'NEXT ART',sans-serif`;
ctx.fillText('\u00d71000 rpm',sx+160,sy-180); ctx.fillText('rpm',sx+160,sy-180);
// redline // redline
ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]); ctx.strokeStyle='rgba(255,40,30,.1)';ctx.lineWidth=1;ctx.setLineDash([3,6]);
@@ -171,7 +171,8 @@ function up(d){const rpm=d.rpm||0,mr=d.max_rpm||8000,g=d.gear||0,sp=d.speed_kmh|
const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%'; const fp=Math.round((d.fuel||1)*100);fF.style.width=fp+'%';fV.textContent=fp+'%';
const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}} const et=d.engine_temp||0;if(et>0){const ep=Math.min(et/120,1)*100;wF.style.width=ep+'%';wV.textContent=Math.round(et)+'\u00B0C'}}
draw(0,8000);apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn() apply();window.addEventListener('resize',()=>{apply();draw(lR>0?lR:0,8000)});cn();
setTimeout(()=>{apply();draw(0,8000)},100);
})(); })();
</script> </script>
</body> </body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+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
+434
View File
@@ -0,0 +1,434 @@
# 📡 ACC Broadcasting API 完整规范
## 一、概述
ACC 有两种遥测数据通道:
| 通道 | 协议 | 用途 |
|---|---|---|
| **Broadcasting API** | UDP(双向) | 广播/观战、实时计时、多车数据 |
| **Shared Memory** | 共享内存(Windows) | 仪表盘、运动平台、FFB(仅玩家车辆) |
SDK 路径:`steamapps/common/Assetto Corsa Competizione/sdk/broadcasting/`
---
## 二、启用 Broadcasting
### 配置文件
编辑 `文档/Assetto Corsa Competizione/Config/broadcasting.json`**必须 UTF-16LE BOM 编码**):
```json
{
"updListenerPort": 9000,
"connectionPassword": "asd",
"commandPassword": ""
}
```
| 字段 | 说明 |
|---|---|
| `updListenerPort` | ACC 监听的 UDP 端口(默认 9000 |
| `connectionPassword` | 客户端连接密码 |
| `commandPassword` | 控制命令密码(留空则所有已连接客户端都可发送命令) |
### 测试客户端
运行 `sdk/broadcasting/Testclient/ksBroadcastingTestClient.exe`,输入端口和密码后点击 Connect 即可验证。
---
## 三、协议规范
### 基本规则
- **字节序**Little Endian
- **协议版本**`BROADCASTING_PROTOCOL_VERSION = 2`
- **字符串编码**:UTF-8,前缀 `uint16` 长度字段
- **通信流程**:客户端 → ACC 发起注册 → ACC 回传数据流
### 数据序列化规则
| 类型 | 字节数 |
|---|---|
| `byte` / `int8` | 1 |
| `uint16` / `int16` | 2 |
| `int32` / `uint32` | 4 |
| `float32` | 4 |
| `string` | 2(长度)+ NUTF-8 内容) |
### Lap 结构体(通用圈速格式)
```
int32 LaptimeMS // 圈速(ms)Int32.MaxValue = 无圈速
uint16 CarIndex // 车辆索引
uint16 DriverIndex // 车手索引
uint8 SplitCount // 分段数(通常 3)
int32[] Splits // 各分段成绩(ms)Int32.MaxValue = 无效
uint8 IsInvalid // 0=有效, 1=无效
uint8 IsValidForBest // 0=不可作最佳圈, 1=可作最佳圈
uint8 IsOutLap // 0=非出场圈, 1=出场圈
uint8 IsInLap // 0=非回场圈, 1=回场圈
```
---
## 四、出站消息(客户端 → ACC)
所有消息首字节为消息类型。
| 类型 ID | 消息名 | 说明 |
|---|---|---|
| `1` | **REGISTER_COMMAND_APPLICATION** | 注册连接(必须最先发送) |
| `9` | UNREGISTER_COMMAND_APPLICATION | 取消注册 |
| `10` | **REQUEST_ENTRY_LIST** | 请求参赛者列表 |
| `11` | **REQUEST_TRACK_DATA** | 请求赛道数据 |
| `49` | CHANGE_HUD_PAGE | 切换 HUD 页面 |
| `50` | **CHANGE_FOCUS** | 切换焦点车辆/摄像机 |
| `51` | **INSTANT_REPLAY_REQUEST** | 请求即时回放 |
| `52` | PLAY_MANUAL_REPLAY_HIGHLIGHT | (预留) |
| `60` | SAVE_MANUAL_REPLAY_HIGHLIGHT | (预留) |
### 1. REGISTER_COMMAND_APPLICATIONID=1
```
byte 1 // 消息类型
byte BROADCASTING_PROTOCOL_VERSION // = 2
string DisplayName // 显示名称
string ConnectionPassword // 连接密码(匹配 broadcasting.json
int32 RealtimeUpdateInterval // 实时更新间隔(ms),建议 250
string CommandPassword // 命令密码
```
### 2. CHANGE_FOCUSID=50
```
byte 50 // 消息类型
int32 ConnectionId // 连接 ID
uint8 HasCarChange // 0=不变, 1=切换车辆
uint16 CarIndex // 目标车辆索引(仅 HasCarChange=1
uint8 HasCameraChange // 0=不变, 1=切换摄像机
string CameraSet // 摄像机集合名
string Camera // 摄像机名
```
### 3. INSTANT_REPLAY_REQUESTID=51
```
byte 51 // 消息类型
int32 ConnectionId
float32 StartSessionTime // 回放起点(会话时间,ms)
float32 DurationMS // 回放时长(ms)
int32 InitialFocusedCar // 初始焦点车辆(-1 = 保持当前)
string InitialCameraSet
string InitialCamera
```
### 4. REQUEST_ENTRY_LISTID=10/ REQUEST_TRACK_DATAID=11
```
byte 10 或 11
int32 ConnectionId
```
---
## 五、入站消息(ACC → 客户端)
| 类型 ID | 消息名 | 说明 |
|---|---|---|
| `1` | **REGISTRATION_RESULT** | 注册结果 |
| `2` | **REALTIME_UPDATE** | 会话实时信息(全局) |
| `3` | **REALTIME_CAR_UPDATE** | 车辆实时信息(每车) |
| `4` | **ENTRY_LIST** | 参赛者索引列表 |
| `5` | **TRACK_DATA** | 赛道和摄像机数据 |
| `6` | **ENTRY_LIST_CAR** | 单辆车详细信息 |
| `7` | **BROADCASTING_EVENT** | 广播事件 |
### 1. REGISTRATION_RESULTID=1
```
byte 1
int32 ConnectionId // 分配的连接 ID(后续请求必须携带)
uint8 ConnectionSuccess // 0=失败, 1=成功
uint8 IsReadOnly // 0=可读写(即拥有 commandPassword
string ErrorMessage // 错误信息
```
成功后应立即发送 `REQUEST_ENTRY_LIST``REQUEST_TRACK_DATA`
---
### 2. REALTIME_UPDATEID=2
```
byte 2
uint16 EventIndex // 赛事索引
uint16 SessionIndex // 会话索引
uint8 SessionType // 见下表
uint8 Phase // 会话阶段(见下表)
float32 SessionTime // 会话已过时间 (ms)
float32 SessionEndTime // 会话剩余时间 (ms)
int32 FocusedCarIndex // 当前焦点车辆
string ActiveCameraSet // 当前摄像机集合
string ActiveCamera // 当前摄像机
string CurrentHUDPage // 当前 HUD 页面
uint8 IsReplayPlaying // 是否回放中
// 以下仅 IsReplayPlaying=1 时存在:
float32 ReplaySessionTime // 回放中的会话时间
float32 ReplayRemainingTime // 回放剩余时间
int32 ReplayFocusedCar
// 始终存在:
uint16 TimeOfDay // 时间 hh*100+mm
uint8 AmbientTemp // 环境温度 (°C)
uint8 TrackTemp // 赛道温度 (°C)
uint8 Clouds // 云量(÷10,即 0.0~1.0
uint8 RainLevel // 降雨量(÷10)
uint8 Wetness // 路面湿度(÷10)
Lap BestSessionLap // 当前会话最佳圈速
```
#### RaceSessionType 枚举
| 值 | 类型 |
|---|---|
| 0 | Practice(练习) |
| 4 | Qualifying(排位赛) |
| 9 | Superpole(超级杆位) |
| 10 | Race(正赛) |
| 11 | Hotlap(单圈冲刺) |
| 12 | Hotstint(阶段冲刺) |
| 13 | HotlapSuperpole |
| 14 | Replay(回放) |
#### SessionPhase 枚举
| 值 | 阶段 |
|---|---|
| 0 | NONE |
| 1 | Starting |
| 2 | PreFormation |
| 3 | FormationLap(编队圈) |
| 4 | PreSession |
| 5 | **Session**(绿灯起跑后) |
| 6 | SessionOver |
| 7 | PostSession |
| 8 | ResultUI |
---
### 3. REALTIME_CAR_UPDATEID=3
**最重要的数据包**—每辆车独立发送。
```
byte 3
uint16 CarIndex // 车辆索引(对应 EntryList
uint16 DriverIndex // 当前车手索引
uint8 DriverCount // 该车车手总数
int8 Gear // -1=R, 0=N, 1~6...
float32 WorldPosX // 世界坐标 X(始终为 0)
float32 WorldPosY // 世界坐标 Y(始终为 0)
float32 Yaw // 偏航角(始终为 0)
uint8 CarLocation // 位置枚举(见下)
uint16 Kmh // 速度 (km/h)
uint16 Position // 排名(1-based
uint16 CupPosition // 组别排名(1-based
uint16 TrackPosition // 赛道位置序号(始终为 0)
float32 SplinePosition // 赛道位置 0.0~1.0
uint16 Laps // 已完成圈数
int32 Delta // 与最佳圈速的实时差距 (ms)
Lap BestSessionLap // 该车最佳圈速
Lap LastLap // 上一圈
Lap CurrentLap // 当前圈(时间持续更新,分段不填充)
```
#### CarLocation 枚举
| 值 | 位置 |
|---|---|
| 0 | NONE |
| 1 | **Track**(赛道上) |
| 2 | Pitlane(维修区通道) |
| 3 | PitEntry(进站入口) |
| 4 | PitExit(出站出口) |
---
### 4. ENTRY_LISTID=4+ ENTRY_LIST_CARID=6
**两步接收**:先收索引列表,再逐车收详情。
#### ENTRY_LIST 结构:
```
byte 4
int32 ConnectionId
uint16 CarEntryCount
uint16[] CarIndexes // 车辆索引数组
uint16 DriverEntryCount
uint16[] DriverIndexes // 保留字段
```
#### ENTRY_LIST_CAR 结构:
```
byte 6
uint16 CarIndex // 对应 ENTRY_LIST 中的索引
uint8 CarModelType // 车型(见下)
string TeamName // 车队名
int32 RaceNumber // 车号
string TeamCarName // 车型名称
string DisplayName // 显示名
uint8 CupCategory // 组别:0=Overall/Pro, 1=ProAm, 2=Am, 3=Silver, 4=National
uint8 CurrentDriverId // 当前车手 ID
uint16 Nationality // 国籍(见附录)
uint8 DriversOnCarCount // 该车手数量
// 每个车手:
uint16 DriverIndex
uint8 HasDriverInfo // 0/1
string FirstName
string LastName
string Nickname
string ShortName // 缩写(如 "VER"
uint8 Category // 3=Platinum, 2=Gold, 1=Silver, 0=Bronze
uint16 Nationality
```
#### CarModelType(部分)
| 值 | 车型 |
|---|---|
| 1 | Mercedes-AMG GT3 |
| 2 | Ferrari 488 GT3 |
| 15 | Lexus RC F GT3 |
| 16 | Lamborghini Huracán GT3 |
| 19 | Audi R8 LMS GT3 |
| 20 | Aston Martin Vantage GT3 |
| 23 | Porsche 911 GT3 R |
---
### 5. TRACK_DATAID=5
```
byte 5
int32 ConnectionId
string TrackName // 赛道名
int32 TrackId // 赛道 ID(见下)
int32 TrackMeters // 赛道长度 (m)
uint8 CameraSetCount
// 每个摄像机集合:
string CameraSetName
uint8 CameraCount
string[] CameraNames // 逐个摄像机名
uint8 HUDPageCount
string[] HUDPageNames
```
#### TrackId(部分)
| ID | 赛道 |
|---|---|
| 1 | Brands Hatch |
| 2 | Spa-Francorchamps |
| 3 | Monza |
| 4 | Misano |
| 5 | Paul Ricard |
| 6 | Silverstone |
| 7 | Hungaroring |
| 8 | Nürburgring |
| 9 | Barcelona |
| 10 | Zolder |
| 11 | Zandvoort |
| 13 | Mount Panorama (Bathurst) |
| 14 | Laguna Seca |
| 15 | Suzuka |
---
### 6. BROADCASTING_EVENTID=7
```
byte 7
uint8 Type // 事件类型
string Msg // 事件消息(常为圈速字符串)
int32 TimeMs // 自连接建立以来的时间 (ms)
int32 CarId // 相关车辆 ID
```
| 事件 Type | 说明 |
|---|---|
| 5 | LapCompleted(完成一圈) |
| 6 | BestSessionLap(全场最佳圈) |
| 7 | BestPersonalLap(个人最佳圈) |
---
## 六、Shared Memory API(补充)
用于本地仪表盘/运动平台,仅含玩家车辆数据。
### 三个内存页
| 页 | 大小 | 更新频率 | 内容 |
|---|---|---|---|
| **SPageFilePhysics** | ~800 B | 物理步进 | 速度、油门、刹车、G力、胎温等 |
| **SPageFileGraphic** | ~852 B | 图形帧率 | 圈速、排名、旗语、天气、油耗等 |
| **SPageFileStatic** | ~688 B | 固定 | 车辆名、赛道、最大RPM等 |
### Physics 关键字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `gas` | float | 油门 0.0~1.0 |
| `brake` | float | 刹车 0.0~1.0 |
| `gear` | int | 档位 |
| `rpm` | int | 引擎转速 |
| `speedKmh` | float | 速度 |
| `steerAngle` | float | 转向角 |
| `wheelPressure[4]` | float | 胎压 |
| `tyreCoreTemp[4]` | float | 轮胎内核温度 |
| `brakeTemp[4]` | float | 刹车温度 |
| `carDamage` | struct | 车辆损伤 |
| `fuel` | float | 油量 (L) |
| `abs` | float | ABS 作用量 |
| `tc` | float | TC 作用量 |
| `localVelocity` | Vector3f | 局部速度 |
| `localAngularVel` | Vector3f | 局部角速度 |
| `gForce` | Vector3f | G力 |
### Graphics 关键字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `status` | enum | AC_OFF=0, AC_LIVE=1 等 |
| `sessionType` | enum | 同 Broadcasting |
| `currentTime` | int | 当前圈速 (ms) |
| `lastTime` | int | 上一圈 (ms) |
| `bestTime` | int | 最佳圈 (ms) |
| `completedLaps` | int | 已完成圈数 |
| `position` | int | 排名 |
| `normalizedCarPosition` | float | 赛道位置 0.0~1.0 |
| `isInPit` | bool | 是否在维修区 |
| `flag` | enum | 旗语 |
| `fuelPerLap` | float | 每圈油耗 |
| `gapAhead` | int | 与前车差距 (ms) |
| `gapBehind` | int | 与后车差距 (ms) |
| `trackStatus` | string | Green/Fast/Optimum/Greasy/Damp/Wet/Flooded |
| `rainIntensity` | enum | 降雨强度 |
| `globalYellow` | bool | 黄旗 |
---
## 七、关键注意事项
1. **WorldPosX/Y 和 Yaw**Broadcasting API 中始终为 0(出于反作弊考虑),需要全车坐标请用 Shared Memory
2. **EntryList**:在首次连接时发送一次,新会话开始时不自动重发
3. **TrackData**:同样只在连接时发送一次,新会话不重发
4. **SplinePosition**:0.0~1.0 表示车辆在赛道上的归一化位置(维修区也有效)
5. **Lap.Splits**Int32.MaxValue 表示无效分段,超出分段数返回 0
6. **CarUpdate 中 Gear**:传输值 - 2 = 实际档位(传输 1 = -1/R,传输 2 = 0/N,传输 3 = 1 档...
7. **broadcasting.json 编码**:必须是 UTF-16LE with BOM,否则 ACC 无法读取
+318
View File
@@ -0,0 +1,318 @@
## 🏎️ F1 24 UDP Telemetry 规范总览
### 📌 官方文档
- **官方规范 Word 文档**[EA Forums — Data Output from F1 24 v27.2x.docx](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-24-general-discussion-en/4624/1/Data%20Output%20from%20F1%2024%20v27.2x.docx)
- **GitHub 开源解析库参考**https://github.com/MacManley/f1-24-udp
---
## ⚙️ 游戏设置
在游戏中启用 UDP 遥测:**Game Options → Settings → Telemetry Settings**
| 设置项 | 推荐值 |
|---|---|
| `UDP Telemetry` | On |
| `UDP Broadcast Mode` | Off(多设备时可开启) |
| `UDP IP Address` | 接收端 IP(本机用 `127.0.0.1` |
| `Port` | `20777`(默认) |
| `UDP Send Rate` | `20Hz`(默认,最高 60Hz |
| `UDP Format` | `2024` |
> ⚠️ 注意:游戏仅在玩家进入 **计时圈(timed lap)** 后才开始发送有效遥测数据。
---
## 📦 数据编码
- **字节序**Little Endian
- **对齐**packed(无填充)
- **最大车辆数**22
### 数据类型
| 类型 | 说明 |
|---|---|
| `uint8` / `int8` | 无符号/有符号 8-bit |
| `uint16` / `int16` | 无符号/有符号 16-bit |
| `uint32` | 无符号 32-bit |
| `uint64` | 无符号 64-bit |
| `float` | 32-bit 浮点 |
---
## 📋 包头结构(PacketHeader
**大小:29 bytes**
```c
struct PacketHeader {
uint16 m_packetFormat; // 2024
uint8 m_gameYear; // 年份后两位,如 24
uint8 m_gameMajorVersion; // 主版本号 "X.00"
uint8 m_gameMinorVersion; // 次版本号 "1.XX"
uint8 m_packetVersion; // 包类型版本号,从 1 开始
uint8 m_packetId; // 包类型 ID(见下表)
uint64 m_sessionUID; // 会话唯一 ID
float m_sessionTime; // 会话时间戳
uint32 m_frameIdentifier; // 帧标识符
uint32 m_overallFrameIdentifier; // 全局帧标识符(Flashback 不回退)
uint8 m_playerCarIndex; // 玩家车辆索引
uint8 m_secondaryPlayerCarIndex; // 分屏第二玩家索引(255 = 无)
};
```
---
## 🆔 包类型 ID 汇总
| ID | 包名称 | 说明 | 频率 | 大小 |
|---|---|---|---|---|
| 0 | **Motion** | 所有车辆物理运动数据 | 菜单设定频率 | 1349 B |
| 1 | **Session** | 会话信息(赛道、剩余时间) | 2/秒 | 753 B |
| 2 | **Lap Data** | 所有车辆圈速数据 | 菜单设定频率 | 1285 B |
| 3 | **Event** | 会话事件 | 事件触发时 | 45 B |
| 4 | **Participants** | 参与者列表 | 每 5 秒 | 1350 B |
| 5 | **Car Setups** | 车辆调校参数 | 2/秒 | 1133 B |
| 6 | **Car Telemetry** | 遥测数据(速度/油门/DRS等) | 菜单设定频率 | 1352 B |
| 7 | **Car Status** | 车辆状态(燃油/ERS等) | 菜单设定频率 | 1239 B |
| 8 | **Final Classification** | 最终排名 | 结果界面每 5 秒 | 1020 B |
| 9 | **Lobby Info** | 多人游戏大厅信息 | 大厅中 2/秒 | 1306 B |
| 10 | **Car Damage** | 车辆损伤数据 | 10/秒 | 953 B |
| 11 | **Session History** | 圈速和轮胎历史 | 20/秒(轮询车辆) | 1460 B |
| 12 | **Tyre Sets** | 轮胎组详细数据 | 2/秒 | 231 B |
| 13 | **Motion Ex** | 玩家车辆扩展运动数据 | 菜单设定频率 | 237 B |
| 14 | **Time Trial** | 计时赛专用数据 | 1/秒(仅TT | 101 B |
---
## 🔍 各包数据结构详解
### 1. Motion PacketID=0
包含所有 22 辆车的物理数据。
```c
struct CarMotionData {
float m_worldPositionX, m_worldPositionY, m_worldPositionZ; // 世界位置 (m)
float m_worldVelocityX, m_worldVelocityY, m_worldVelocityZ; // 世界速度 (m/s)
int16 m_worldForwardDirX, m_worldForwardDirY, m_worldForwardDirZ; // 前向向量(÷32767 归一化)
int16 m_worldRightDirX, m_worldRightDirY, m_worldRightDirZ; // 右向向量
float m_gForceLateral, m_gForceLongitudinal, m_gForceVertical; // G 力分量
float m_yaw, m_pitch, m_roll; // 角度 (rad)
};
// PacketMotionData: PacketHeader + CarMotionData[22]
```
---
### 2. Session PacketID=1
包含当前会话完整信息,如天气、温度、赛道、规则、辅助设置等。
关键字段:
- `m_weather`: 0=晴, 1=少云, 2=阴, 3=小雨, 4=大雨, 5=暴雨
- `m_trackId`: 赛道 ID-1=未知,见附录)
- `m_sessionType`: 会话类型
- `m_formula`: 0=F1 Modern, 1=F1 Classic, 2=F2, 3=F1 Generic, 4=Beta, 6=Esports, 8=F1 World, 9=F1 Elimination
- `m_safetyCarStatus`: 0=无, 1=实体安全车, 2=虚拟安全车, 3=编队圈
- `m_weatherForecastSamples[64]`: 天气预测(最多 64 样本,适配冲刺赛周末)
- `m_weekendStructure[12]`: 周末赛程结构
- `m_sector2LapDistanceStart` / `m_sector3LapDistanceStart`: 赛段起点距离
---
### 3. Lap Data PacketID=2
```c
struct LapData {
uint32 m_lastLapTimeInMS; // 上一圈时间 (ms)
uint32 m_currentLapTimeInMS; // 当前圈耗时 (ms)
uint16 m_sector1TimeMSPart; // S1 毫秒部分
uint8 m_sector1TimeMinutesPart; // S1 分钟部分
uint16 m_sector2TimeMSPart; // S2 毫秒部分
uint8 m_sector2TimeMinutesPart; // S2 分钟部分
uint16 m_deltaToCarInFrontMSPart; // 与前车差距毫秒
uint8 m_deltaToCarInFrontMinutesPart; // 与前车差距分钟
uint16 m_deltaToRaceLeaderMSPart; // 与领跑者差距毫秒
uint8 m_deltaToRaceLeaderMinutesPart; // 与领跑者差距分钟
float m_lapDistance; // 当前圈已跑距离 (m)
float m_totalDistance; // 总行驶距离 (m)
float m_safetyCarDelta; // 安全车时间差 (s)
uint8 m_carPosition; // 赛道排名
uint8 m_currentLapNum; // 当前圈数
uint8 m_pitStatus; // 0=无, 1=进站中, 2=维修区
uint8 m_numPitStops; // 已进站次数
uint8 m_sector; // 0=S1, 1=S2, 2=S3
uint8 m_currentLapInvalid; // 0=有效, 1=无效
uint8 m_penalties; // 累计罚时 (s)
uint8 m_driverStatus; // 0=车库, 1=飞行圈, 2=回场圈, 3=出场圈, 4=赛道上
uint8 m_resultStatus; // 0-7 (invalid/active/finished/DNF/DSQ等)
float m_speedTrapFastestSpeed; // 速度陷阱最快速度 (km/h)
// ... 等
};
```
---
### 4. Event PacketID=3
事件代码:
| 代码 | 事件 | 代码 | 事件 |
|---|---|---|---|
| `SSTA` | 会话开始 | `SEND` | 会话结束 |
| `FTLP` | 最快圈速 | `RTMT` | 退赛 |
| `DRSE` | DRS 启用 | `DRSD` | DRS 禁用 |
| `TMPT` | 队友进站 | `CHQF` | 方格旗 |
| `RCWN` | 冠军产生 | `PENA` | 处罚发出 |
| `SPTP` | 速度陷阱触发 | `STLG` | 发车灯 |
| `LGOT` | 灯灭起跑 | `DTSV` | Drive-through 已执行 |
| `SGSV` | Stop-Go 已执行 | `FLBK` | Flashback |
| `BUTN` | 按钮状态 | `RDFL` | 红旗 |
| `OVTK` | 超车 | `SCAR` | 安全车事件 |
| `COLL` | 碰撞事件 | | |
---
### 5. Car Telemetry PacketID=6
```c
struct CarTelemetryData {
uint16 m_speed; // 速度 (km/h)
float m_throttle; // 油门 (0.0~1.0)
float m_steer; // 转向 (-1.0~1.0)
float m_brake; // 刹车 (0.0~1.0)
uint8 m_clutch; // 离合 (0~100)
int8 m_gear; // 档位 (1-8, N=0, R=-1)
uint16 m_engineRPM; // 引擎转速
uint8 m_drs; // DRS: 0=关, 1=开
uint8 m_revLightsPercent; // 转速灯百分比
uint16 m_revLightsBitValue; // 转速灯位值
uint16 m_brakesTemperature[4]; // 刹车温度 (°C)
uint8 m_tyresSurfaceTemperature[4]; // 胎面温度 (°C)
uint8 m_tyresInnerTemperature[4]; // 胎内温度 (°C)
uint16 m_engineTemperature; // 引擎温度 (°C)
float m_tyresPressure[4]; // 胎压 (PSI)
uint8 m_surfaceType[4]; // 路面类型
};
```
---
### 6. Car Status PacketID=7
```c
struct CarStatusData {
uint8 m_tractionControl; // 0=关, 1=中, 2=全
uint8 m_antiLockBrakes; // 0=关, 1=开
uint8 m_fuelMix; // 0=Lean, 1=Standard, 2=Rich, 3=Max
uint8 m_frontBrakeBias; // 前刹车比 (%)
float m_fuelInTank; // 燃油量 (kg)
float m_fuelCapacity; // 油箱容量
float m_fuelRemainingLaps; // 剩余油量可跑圈数
uint8 m_drsAllowed; // DRS 是否允许
uint16 m_drsActivationDistance; // DRS 激活距离 (m)
uint8 m_actualTyreCompound; // 实际轮胎配方 (16=C5, 17=C4, 18=C3, 19=C2, 20=C1, 21=C0, 7=半雨, 8=全雨)
uint8 m_visualTyreCompound; // 视觉轮胎配方 (16=软, 17=中, 18=硬)
uint8 m_tyresAgeLaps; // 轮胎使用圈数
int8 m_vehicleFiaFlags; // FIA 旗帜 (-1=未知, 0=无, 1=绿, 2=蓝, 3=黄)
float m_enginePowerICE; // 内燃机功率 (W)
float m_enginePowerMGUK; // MGU-K 功率 (W)
float m_ersStoreEnergy; // ERS 储能 (J)
uint8 m_ersDeployMode; // 0=无, 1=Medium, 2=Hotlap, 3=Overtake
float m_ersHarvestedThisLapMGUK; // MGU-K 回收能量
float m_ersHarvestedThisLapMGUH; // MGU-H 回收能量
float m_ersDeployedThisLap; // 已使用 ERS 能量
};
```
---
### 7. Car Damage PacketID=10
```c
struct CarDamageData {
float m_tyresWear[4]; // 轮胎磨损 (%)
uint8 m_tyresDamage[4]; // 轮胎损伤 (%)
uint8 m_brakesDamage[4]; // 刹车损伤 (%)
uint8 m_frontLeftWingDamage; // 前左翼损伤 (%)
uint8 m_frontRightWingDamage; // 前右翼损伤 (%)
uint8 m_rearWingDamage; // 尾翼损伤 (%)
uint8 m_floorDamage; // 底板损伤 (%)
uint8 m_diffuserDamage; // 扩散器损伤 (%)
uint8 m_sidepodDamage; // 侧箱损伤 (%)
uint8 m_drsFault; // DRS 故障 (0/1)
uint8 m_ersFault; // ERS 故障 (0/1)
uint8 m_gearBoxDamage; // 变速箱损伤 (%)
uint8 m_engineDamage; // 引擎损伤 (%)
uint8 m_engineMGUHWear; // MGU-H 磨损 (%)
uint8 m_engineESWear; // ES 磨损 (%)
uint8 m_engineCEWear; // CE 磨损 (%)
uint8 m_engineICEWear; // ICE 磨损 (%)
uint8 m_engineMGUKWear; // MGU-K 磨损 (%)
uint8 m_engineTCWear; // TC 磨损 (%)
uint8 m_engineBlown; // 引擎爆缸 (0/1)
uint8 m_engineSeized; // 引擎卡死 (0/1)
};
```
---
### 8. Motion Ex PacketID=13)— 运动平台关键包
专为 **运动模拟平台(D-BOX 等)** 设计,包含更多细节:
```c
float m_suspensionPosition[4]; // 悬挂位置 [RL, RR, FL, FR]
float m_suspensionVelocity[4]; // 悬挂速度
float m_suspensionAcceleration[4]; // 悬挂加速度
float m_wheelSpeed[4]; // 各轮速度
float m_wheelSlipRatio[4]; // 滑移率
float m_wheelSlipAngle[4]; // 滑移角
float m_wheelLatForce[4]; // 横向力
float m_wheelLongForce[4]; // 纵向力
float m_wheelVertForce[4]; // 垂向力
float m_heightOfCOGAboveGround; // 重心高度
float m_localVelocityX/Y/Z; // 局部速度
float m_angularVelocityX/Y/Z; // 角速度 (rad/s)
float m_angularAccelerationX/Y/Z; // 角加速度 (rad/s²)
float m_frontWheelsAngle; // 前轮转角 (rad)
float m_frontAeroHeight; // 前底盘高度
float m_rearAeroHeight; // 后底盘高度
float m_frontRollAngle; // 前悬挂侧倾角
float m_rearRollAngle; // 后悬挂侧倾角
float m_chassisYaw; // 底盘偏航角 (rad) — 可用于判断侧滑
```
---
### 9. 其余包简介
| 包 | 关键内容 |
|---|---|
| **Final Classification (8)** | 最终排名、圈数、积分、最佳圈速、轮胎使用记录 |
| **Lobby Info (9)** | 多人游戏大厅的玩家信息 |
| **Session History (11)** | 每辆车的全部圈速历史 + 轮胎使用历史(最多 100 圈) |
| **Tyre Sets (12)** | 轮胎组详情:配方、磨损度、可用性、圈速差等 |
| **Time Trial (14)** | 计时赛专属:玩家最佳、个人最佳、对手数据 |
---
## 🔒 隐私:Your Telemetry 设置
当玩家设置 `Your Telemetry = Restricted` 时,以下数据对其他玩家**归零**:
- **Car Status**:燃油、ERS 相关数据
- **Car Damage**:所有损伤数据
- **Tyre Sets**:全部轮胎组数据
---
## 🆕 F1 24 相比 F1 23 的主要变化
1. 会话包中新增模拟/规则选项
2. 新增安全车事件详情(`SCAR`
3. 大厅和参与者包中新增在线名称、Your Telemetry、F1 World Tech Level
4. 圈速包中新增与前车/领先者的分钟差距字段
5. 最终排名包改为结果界面每 5 秒重复发送
6. 新增 **Time Trial 包**ID=14
7. Motion Ex 包新增底盘高度(plank height)、悬挂侧倾角、底盘偏航角
8. 新增碰撞事件(`COLL`
9. 新增周末赛程结构(`m_weekendStructure`
10. 天气预测数组从 56 扩展到 64
11. 圈速包新增速度陷阱数据
12. Car Setup 包新增发动机制动参数 + 下次进站前翼值
+1 -11
View File
@@ -238,12 +238,6 @@
- **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节** - **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节**
- **FH4**:和 FH5 基本一致 - **FH4**:和 FH5 基本一致
好问题!您确实很难在游戏中见到 P 档和 N 档,因为正常驾驶时它们几乎不出现。让我查一下社区中关于这个细节的讨论。
让我搜索更详细的信息。让我再搜一下 GitHub 上关于这个问题的实际讨论。让我直接看看社区中资深开发者对 Gear 字段的完整说明。
根据搜索结果和实际代码分析,以下是关于 **N 档(Neutral****P 档(Park** 在 FH4/FH5/FH6 UDP 数据包中的完整定义:
--- ---
## Gear 字段的完整取值范围 ## Gear 字段的完整取值范围
@@ -259,11 +253,7 @@
| **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 | | **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 |
| **22+** | 理论上可能更高(如 D 档标记) | 极少见 | | **22+** | 理论上可能更高(如 D 档标记) | 极少见 |
### 关键发现:P 档 = 21 ### Gear 值说明
社区中一位资深开发者(*0x20F*Forza Telemetry 核心开发者)明确指出:
> **Gear 21 = P (Park)**
这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。 这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。
+752
View File
@@ -0,0 +1,752 @@
# 📡 iRacing Telemetry APIIRSDK)完整规范
## 一、概述
iRacing 的遥测系统通过 **共享内存映射文件(Memory-Mapped File** 工作,所有数据是**只读**的。API 版本当前为 **IRSDK_VER = 2**
### 三种数据通道
| 通道 | 形式 | 频率 | 说明 |
|---|---|---|---|
| **Live Telemetry** | 共享内存 `Local\IRSDKMemMapFileName` | 60Hz(或 360Hz) | 实时变量数据,三缓冲机制 |
| **Session String** | 共享内存中的 YAML 字符串 | 变更时更新 | 会话元数据(赛道、车手、天气等) |
| **Disk Telemetry (.ibt)** | 二进制文件 | 60Hz | 按 `Alt+L` 录制,结构等同于 Live 数据 |
### 启用遥测
在 iRacing 的 `app.ini` 中确认:
```ini
[External Telemetry]
enableTelemetry=1 # 启用遥测
enableIRSDKTelemetry=1 # 启用 IRSDK
```
---
## 二、核心架构
### 共享内存布局
```
[irsdk_header]
[irsdk_varHeader[0] ... irsdk_varHeader[numVars-1]]
[Session Info YAML String]
[irsdk_varBuf[0]] → [Telemetry Data Line 0]
[irsdk_varBuf[1]] → [Telemetry Data Line 1]
[irsdk_varBuf[2]] → [Telemetry Data Line 2]
[irsdk_varBuf[3]] → [Telemetry Data Line 3] (IRSDK_MAX_BUFS = 4)
```
- **三缓冲**:模拟器写满一个缓冲区后立即切到下一个,给客户端 16ms+ 窗口来读取
- **同步事件**`Local\IRSDKDataValidEvent` — 新数据就绪时触发
### 字节序与编码
- **字节序**Little Endian
- **YAML 编码**UTF-8
- **对齐**16 字节对齐
- **变量名最大长**`IRSDK_MAX_STRING = 32`
- **描述最大长**`IRSDK_MAX_DESC = 64`
---
## 三、数据结构
### 1. irsdk_header(主头部)
```c
struct irsdk_header {
int ver; // API 版本 = 2
int status; // 位字段 (irsdk_stConnected = 1)
int tickRate; // tick 频率 (60 或 360)
// 会话信息(变更时更新)
int sessionInfoUpdate; // 会话信息变更计数
int sessionInfoLen; // YAML 字符串长度(字节)
int sessionInfoOffset; // 距头部的偏移
// 遥测变量
int numVars; // 变量数量
int varHeaderOffset; // varHeader 数组偏移
int numBuf; // 缓冲区数量(4
int bufLen; // 每行字节数
int pad1[2]; // 16 字节对齐
irsdk_varBuf varBuf[IRSDK_MAX_BUFS]; // 4 个缓冲区
};
```
### 2. irsdk_varHeader(变量描述头)
```c
struct irsdk_varHeader {
int type; // irsdk_VarType 枚举
int offset; // 在缓冲区行中的偏移
int count; // 数组元素数(标量 = 1
bool countAsTime;
char pad[3]; // 16 字节对齐
char name[IRSDK_MAX_STRING]; // 变量名,如 "Speed"
char desc[IRSDK_MAX_DESC]; // 描述,如 "GPS vehicle speed"
char unit[IRSDK_MAX_STRING]; // 单位,如 "m/s"
};
```
### 3. irsdk_varBuf(数据缓冲区)
```c
struct irsdk_varBuf {
int tickCount; // 检测数据更新
int bufOffset; // 距头部的偏移
int pad[2]; // 16 字节对齐
};
```
### 4. irsdk_VarType(变量类型)
| 枚举值 | 类型 | 字节数 |
|---|---|---|
| `irsdk_char = 0` | char | 1 |
| `irsdk_bool` | bool | 1 |
| `irsdk_int` | int32 | 4 |
| `irsdk_bitField` | bitfield (int32) | 4 |
| `irsdk_float` | float32 | 4 |
| `irsdk_double` | float64 | 8 |
### 5. irsdk_diskSubHeader(仅 .ibt 文件)
```c
struct irsdk_diskSubHeader {
time_t sessionStartDate;
double sessionStartTime;
double sessionEndTime;
int sessionLapCount;
int sessionRecordCount;
};
```
---
## 四、Session StringYAML 结构)
YAML 字符串通过 `irsdk_getSessionInfoStr()` 获取,包含以下顶级节点:
### WeekendInfo
```yaml
WeekendInfo:
TrackName: "suzuka"
TrackID: 123
TrackLength: "5807 m"
TrackDisplayName: "Suzuka International Racing Course"
TrackCity: "Suzuka"
TrackCountry: "JPN"
TrackNumTurns: 18
TrackPitSpeedLimit: "80 kph"
TrackType: "road"
TrackWeatherType: "Dynamic"
TrackSkies: "Partly Cloudy"
TrackSurfaceTemp: "38 C"
TrackAirTemp: "25 C"
TrackWindVel: "3 m/s"
TrackWindDir: "180 deg"
TrackRelativeHumidity: "55 %"
TrackFogLevel: "0 %"
SeriesID: 100
SeasonID: 2024
SessionID: 456
SubSessionID: 789
LeagueID: 0
Official: 1
RaceWeek: 5
EventType: "Race"
Category: "Road"
SimMode: "full"
TeamRacing: 0
MinDrivers: 6
MaxDrivers: 26
NumCarClasses: 1
NumCarTypes: 3
WeekendOptions:
NumStarters: 20
StartingGrid: "2x2"
QualifyScoring: "single"
CourseCautions: "full"
StandingStart: 0
Restarts: "singleFile"
WeatherType: "Dynamic"
Skies: "Partly Cloudy"
WindDirection: "180 deg"
WindSpeed: "3 m/s"
WeatherTemp: "25 C"
RelativeHumidity: "55 %"
FogLevel: "0 %"
Unofficial: 0
IsFixedSetup: 0
HardcoreLevel: 1
```
### SessionInfo
```yaml
SessionInfo:
Sessions:
- SessionNum: 0
SessionLaps: "unlimited"
SessionTime: "30 min"
SessionType: "Practice"
ResultsPositions: []
ResultsFastestLap: []
- SessionNum: 1
SessionLaps: "2 laps"
SessionTime: "10 min"
SessionType: "Lone Qualify"
- SessionNum: 2
SessionLaps: "12 laps"
SessionTime: "30 min"
SessionType: "Race"
```
### DriverInfo
```yaml
DriverInfo:
DriverCarIdx: 0
DriverCarIdleRPM: 3500.0
DriverCarRedLine: 9500.0
DriverCarSLFirstRPM: 8500.0
DriverCarSLShiftRPM: 9000.0
DriverCarSLLastRPM: 9500.0
DriverCarSLBlinkRPM: 9200.0
Drivers:
- CarIdx: 0
UserName: "John Doe"
AbbrevName: "J. Doe"
Initials: "JD"
CarNumber: "23"
CarPath: "mercedesamggt3"
CarClassID: 1
IRating: 2500
LicString: "A 4.99"
IsSpectator: 0
```
### CameraInfo
```yaml
CameraInfo:
Groups:
- GroupNum: 1
GroupName: "TV1"
Cameras:
- CameraNum: 1
CameraName: "TV1_Turn1"
```
### SplitTimeInfo
```yaml
SplitTimeInfo:
Sectors:
- SectorNum: 0
SectorStartPct: 0.0
- SectorNum: 1
SectorStartPct: 0.35
- SectorNum: 2
SectorStartPct: 0.70
```
---
## 五、核心遥测变量列表(完整)
### 🏎️ 玩家车辆控制
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `SteeringWheelAngle` | float | rad | 方向盘转角 |
| `Throttle` | float | % (0-1) | 油门(处理后的) |
| `Brake` | float | % (0-1) | 刹车(处理后的) |
| `Clutch` | float | % (0-1) | 离合 |
| `Gear` | int | — | -1=R0=N1~n=前进档 |
| `RPM` | float | revs/min | 引擎转速 |
| `ThrottleRaw` | float | % (0-1) | 原始油门输入 |
| `BrakeRaw` | float | % (0-1) | 原始刹车输入 |
| `HandbrakeRaw` | float | % (0-1) | 原始手刹输入 |
| `Speed` | float | m/s | GPS 车速 |
| `OnPitRoad` | bool | — | 是否在维修区通道 |
### 📐 位置与运动
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `Yaw` | float | rad | 偏航角 |
| `YawNorth` | float | rad | 相对北方的偏航角 |
| `Pitch` | float | rad | 俯仰角 |
| `Roll` | float | rad | 侧倾角 |
| `YawRate` / `YawRate_ST` | float | rad/s | 偏航率(ST=子tick精度) |
| `PitchRate` / `PitchRate_ST` | float | rad/s | 俯仰率 |
| `RollRate` / `RollRate_ST` | float | rad/s | 侧倾率 |
| `VelocityX` / `VelocityX_ST` | float | m/s | X 方向速度 |
| `VelocityY` / `VelocityY_ST` | float | m/s | Y 方向速度 |
| `VelocityZ` / `VelocityZ_ST` | float | m/s | Z 方向速度 |
| `LatAccel` / `LatAccel_ST` | float | m/s² | 横向加速度 |
| `LongAccel` / `LongAccel_ST` | float | m/s² | 纵向加速度 |
| `VertAccel` / `VertAccel_ST` | float | m/s² | 垂向加速度 |
> `_ST` 后缀变量提供 **子 tick 精度**,适合运动平台等精密场景。
### ⏱️ 圈速与计时
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `Lap` | int | — | 当前圈号 |
| `LapCompleted` | int | — | 已完成圈数 |
| `LapDist` | float | m | 当前圈已跑距离 |
| `LapDistPct` | float | % | 当前圈完成百分比 |
| `RaceLaps` | int | — | 比赛中已完成圈数 |
| `LapBestLap` | int | — | 最佳圈号 |
| `LapBestLapTime` | float | s | 最佳圈速 |
| `LapLastLapTime` | float | s | 上一圈用时 |
| `LapCurrentLapTime` | float | s | 当前圈用时(F3 黑盒) |
| `LapBestNLapLap` | int | — | 最佳 N 圈均值的最后一圈 |
| `LapBestNLapTime` | float | s | 最佳 N 圈平均时间 |
| `LapLastNLapTime` | float | s | 最近 N 圈平均时间 |
| `LapLasNLapSeq` | int | — | 连续干净圈数 |
### 🎯 Delta 时间
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `LapDeltaToBestLap` | float | s | 与个人最佳圈的差距 |
| `LapDeltaToBestLap_DD` | float | s/s | 差距变化率 |
| `LapDeltaToBestLap_OK` | bool | — | delta 数据是否有效 |
| `LapDeltaToOptimalLap` | float | s | 与理论最佳圈的差距 |
| `LapDeltaToOptimalLap_DD` | float | s/s | 差距变化率 |
| `LapDeltaToOptimalLap_OK` | bool | — | 是否有效 |
| `LapDeltaToSessionBestLap` | float | s | 与全场最佳圈差距 |
| `LapDeltaToSessionBestLap_DD` | float | s/s | 差距变化率 |
| `LapDeltaToSessionBestLap_OK` | bool | — | 是否有效 |
| `LapDeltaToSessionOptimalLap` | float | s | 与全场理论最佳差距 |
| `LapDeltaToSessionLastlLap` | float | s | 与全场上一圈差距 |
### ⛽ 引擎 & 燃油
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `FuelLevel` | float | L | 剩余油量(升) |
| `FuelLevelPct` | float | % | 剩余油量百分比 |
| `FuelPress` | float | bar | 燃油压力 |
| `FuelUsePerHour` | float | kg/h | 瞬时油耗率 |
| `ManifoldPress` | float | bar | 进气歧管压力 |
| `OilLevel` | float | L | 机油液位 |
| `OilPress` | float | bar | 机油压力 |
| `OilTemp` | float | °C | 机油温度 |
| `WaterLevel` | float | L | 冷却液液位 |
| `WaterTemp` | float | °C | 冷却液温度 |
| `Voltage` | float | V | 电压 |
| `EngineWarnings` | bitfield | — | 引擎警告灯(见下文) |
| `BrakeABSactive` | bool | — | ABS 是否激活 |
### 🛞 轮胎数据(每轮 LF/RF/LR/RR
`X` ∈ {LF, RF, LR, RR}
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `XcoldPressure` | float | kPa | 冷胎压 |
| `Xpressure` | float | kPa | 当前胎压 |
| `XtempCL/M/R` | float | °C | 胎面左/中/右温度 |
| `XwearL/M/R` | float | % | 胎面左/中/右磨损 |
| `XbrakeLinePress` | float | bar | 刹车管压力 |
| `XshockDefl` | float | mm | 悬挂压缩量 |
| `XshockVel` | float | mm/s | 悬挂速度 |
| `XrideHeight` | float | mm | 行驶高度 |
| `Xspeed` | float | rad/s | 轮速 |
### 🎛️ 力反馈
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `SteeringWheelTorque` | float | N·m | 转向轴输出扭矩 |
| `SteeringWheelTorque_ST` | float | N·m | 转向轴扭矩(子tick |
| `SteeringWheelPctTorque` | float | % | FFB 扭矩百分比(无符号) |
| `SteeringWheelPctTorqueSign` | float | % | FFB 扭矩百分比(有符号) |
| `SteeringWheelPctTorqueSignStops` | float | % | FFB 扭矩百分比(含止挡) |
| `SteeringWheelPctDamper` | float | % | FFB 阻尼百分比 |
| `SteeringWheelAngleMax` | float | rad | 方向盘最大转角 |
| `SteeringWheelPeakForceNm` | float | N·m | FFB 峰值力映射 |
| `SteeringWheelMaxForceNm` | float | N·m | 最大力 |
| `SteeringWheelUseLinear` | bool | — | 是否线性模式 |
### 🏁 会话状态
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `SessionTime` | double | s | 当前会话已过时间 |
| `SessionTimeRemain` | double | s | 会话剩余时间 |
| `SessionTimeTotal` | double | s | 会话总时间 |
| `SessionLapsRemain` | int | — | 剩余圈数 |
| `SessionLapsRemainEx` | int | — | 扩展剩余圈数 |
| `SessionLapsTotal` | int | — | 总圈数 |
| `SessionNum` | int | — | 会话序号 |
| `SessionState` | int | — | 会话状态(见枚举) |
| `SessionUniqueID` | int | — | 会话唯一 ID |
| `SessionFlags` | bitfield | — | 旗语(见下文) |
| `SessionTick` | int | — | Tick 计数 |
| `SessionTimeOfDay` | float | s | 赛道时间 |
### 🚩 旗语 & 状态
**SessionFlags** 位字段:
| 位标记 | 含义 |
|---|---|
| `0x00000001` | 🏁 方格旗 |
| `0x00000002` | ⬜ 白旗 |
| `0x00000004` | 🟩 绿旗 |
| `0x00000008` | 🟨 黄旗 |
| `0x00000010` | 🟥 红旗 |
| `0x00000020` | 🟦 蓝旗 |
| `0x00000040` | 💥 碎片 |
| `0x00000100` | 🟨 黄旗挥动 |
| `0x00000200` | 绿旗前最后一圈 |
| `0x00004000` | ⚠️ Full Course Caution |
| `0x00010000` | ⬛ 黑旗 |
| `0x00020000` | ❌ DSQ |
| `0x10000000` | 🔴 发车灯隐藏 |
| `0x20000000` | 🔴 发车灯就绪 |
| `0x40000000` | 🔴 发车灯 Set |
| `0x80000000` | 🟢 发车灯 Go |
**EngineWarnings** 位字段:
| 位标记 | 含义 |
|---|---|
| `0x0001` | 水温警告 |
| `0x0002` | 燃油压力警告 |
| `0x0004` | 机油压力警告 |
| `0x0008` | 引擎熄火 |
| `0x0010` | Pit Speed Limiter |
| `0x0020` | 转速限制器激活 |
| `0x0040` | 油温警告 |
### 🚗 多车数据(CarIdx 数组,最多 64 辆车)
| 变量名 | 类型 | 说明 |
|---|---|---|
| `CarIdxLap` | int[] | 每辆车的圈数 |
| `CarIdxLapCompleted` | int[] | 每辆车已完成圈数 |
| `CarIdxLapDistPct` | float[] | 每辆车的位置百分比 |
| `CarIdxPosition` | int[] | 每辆车的排名 |
| `CarIdxClassPosition` | int[] | 组别排名 |
| `CarIdxClass` | int[] | 组别 ID |
| `CarIdxF2Time` | float[] | 与领先者的差距(或最快圈速) |
| `CarIdxEstTime` | float[] | 预计到达当前位置的时间 |
| `CarIdxLastLapTime` | float[] | 上一圈时间 |
| `CarIdxBestLapTime` | float[] | 最佳圈时间 |
| `CarIdxBestLapNum` | int[] | 最佳圈号 |
| `CarIdxSteer` | float[] | 方向盘转角 |
| `CarIdxRPM` | float[] | 引擎转速 |
| `CarIdxGear` | int[] | 档位 |
| `CarIdxOnPitRoad` | bool[] | 是否在维修区 |
| `CarIdxTrackSurface` | int[] | 赛道位置类型 |
| `CarIdxTrackSurfaceMaterial` | int[] | 路面材质 |
| `CarIdxTireCompound` | int[] | 轮胎配方 |
| `CarIdxP2P_Status` | bool[] | Push-to-Pass 状态 |
| `CarIdxP2P_Count` | int[] | Push-to-Pass 剩余次数 |
### 🛑 进站 & 维修
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `PitsOpen` | bool | — | 维修区是否开放 |
| `PitstopActive` | bool | — | 正在进站 |
| `PitRepairLeft` | float | s | 强制维修剩余时间 |
| `PitOptRepairLeft` | float | s | 可选维修剩余时间 |
| `PitSvFlags` | bitfield | — | 进站服务勾选 |
| `PitSvLFP/RFP/LRP/RRP` | float | kPa | 指定胎压 |
| `PitSvFuel` | float | L | 指定加油量 |
| `PitSvTireCompound` | int | — | 指定轮胎配方 |
| `FastRepairUsed` | int | — | 已用快速维修次数 |
| `FastRepairAvailable` | int | — | 可用快速维修次数 |
| `PlayerCarInPitStall` | bool | — | 玩家在维修位 |
| `PlayerCarPitSvStatus` | int | — | 进站服务状态 |
**PitSvFlags** 位字段:
| 位标记 | 含义 |
|---|---|
| `0x0001` | 左前换胎 |
| `0x0002` | 右前换胎 |
| `0x0004` | 左后换胎 |
| `0x0008` | 右后换胎 |
| `0x0010` | 加油 |
| `0x0020` | 撕膜 |
| `0x0040` | 快速维修 |
### 🌦️ 天气 & 环境
| 变量名 | 类型 | 单位 | 说明 |
|---|---|---|---|
| `TrackTemp` | float | °C | 赛道温度(起终点) |
| `TrackTempCrew` | float | °C | 赛道温度(Pit 区) |
| `TrackWetness` | float | — | 赛道湿度级别 |
| `AirTemp` | float | °C | 空气温度 |
| `AirDensity` | float | kg/m³ | 空气密度 |
| `AirPressure` | float | Hg | 气压 |
| `WindVel` | float | m/s | 风速 |
| `WindDir` | float | rad | 风向 |
| `RelativeHumidity` | float | % | 相对湿度 |
| `FogLevel` | float | % | 雾级别 |
| `WeatherType` | int | — | 0=恒定,1=动态 |
| `Skies` | int | — | 0=晴,1=少云,2=多云,3=阴 |
### 🎥 回放 & 系统
| 变量名 | 类型 | 说明 |
|---|---|---|
| `IsReplayPlaying` | bool | 是否回放中 |
| `ReplayFrameNum` | int | 回放当前帧号 |
| `ReplayFrameNumEnd` | int | 回放末尾帧号 |
| `ReplayPlaySpeed` | int | 回放速度 |
| `ReplayPlaySlowMotion` | bool | 是否慢动作 |
| `ReplaySessionTime` | double | 回放中的会话时间 |
| `ReplaySessionNum` | int | 回放会话序号 |
| `IsOnTrack` | bool | 玩家是否在赛道上 |
| `IsInGarage` | bool | 是否在车库 |
| `IsDiskLoggingEnabled` | bool | 磁盘遥测是否开启 |
| `IsDiskLoggingActive` | bool | 是否正在录制 .ibt |
### 👥 车手变更(Team Racing
| 变量名 | 类型 | 说明 |
|---|---|---|
| `DCLapStatus` | int | Driver Change 圈数状态 |
| `DCDriversSoFar` | int | 已驾驶过的车手数 |
### 📷 摄像机
| 变量名 | 类型 | 说明 |
|---|---|---|
| `CamCarIdx` | int | 当前焦点车辆索引 |
| `CamCameraNumber` | int | 当前摄像机编号 |
| `CamGroupNumber` | int | 当前摄像机组编号 |
| `CamCameraState` | bitfield | 摄像机状态位字段 |
---
## 六、重要枚举汇总
### SessionState
| 值 | 状态 |
|---|---|
| 0 | StateInvalid |
| 1 | StateGetInCar(上车中) |
| 2 | StateWarmup(暖胎圈) |
| 3 | StateParadeLaps(编队圈) |
| 4 | **StateRacing**(正赛中) |
| 5 | StateCheckered(方格旗) |
| 6 | StateCoolDown(冷却圈) |
### TrkLoc(赛道位置)
| 值 | 含义 |
|---|---|
| -1 | NotInWorld |
| 0 | OffTrack |
| 1 | InPitStall(维修区) |
| 2 | AproachingPits |
| 3 | OnTrack |
### TrkSurf(路面材质)
| 值 | 材质 |
|---|---|
| 0 | Undefined |
| 1-4 | Asphalt |
| 5-6 | Concrete |
| 7-8 | Racing Dirt |
| 9-10 | Paint |
| 11-14 | Rumble Strip(路肩) |
| 15-18 | Grass |
| 19-22 | Dirt |
| 23 | Sand |
| 24-25 | Gravel |
| 26 | Grasscrete |
| 27 | Astroturf |
### TrackWetness(赛道湿度)
| 值 | 级别 |
|---|---|
| 0 | Unknown |
| 1 | Dry |
| 2 | Mostly Dry |
| 3 | Very Lightly Wet |
| 4 | Lightly Wet |
| 5 | Moderately Wet |
| 6 | Very Wet |
| 7 | Extremely Wet |
### PaceMode
| 值 | 模式 |
|---|---|
| 0 | Single File Start |
| 1 | Double File Start |
| 2 | Single File Restart |
| 3 | Double File Restart |
| 4 | Not Pacing |
### CarLeftRight
| 值 | 含义 |
|---|---|
| 0 | Off |
| 1 | Clear(两侧无车) |
| 2 | Car Left |
| 3 | Car Right |
| 4 | Cars on Both Sides |
| 5 | 2 Cars Left |
| 6 | 2 Cars Right |
---
## 七、SDK 客户端 API(C 语言接口)
```c
// 初始化 / 关闭
bool irsdk_startup();
void irsdk_shutdown();
// 连接检测
bool irsdk_isConnected();
// 获取头部
const irsdk_header* irsdk_getHeader();
// 等待新数据并复制到本地缓冲区(推荐方式)
bool irsdk_waitForDataReady(int timeOut, char *data);
// 获取会话 YAML 字符串
const char* irsdk_getSessionInfoStr();
int irsdk_getSessionInfoStrUpdate(); // 检测会话信息更新
// 获取变量头
const irsdk_varHeader* irsdk_getVarHeaderPtr();
const irsdk_varHeader* irsdk_getVarHeaderEntry(int index);
// 按名称查找变量
int irsdk_varNameToIndex(const char *name);
int irsdk_varNameToOffset(const char *name);
// 获取指定缓冲区的数据
const char* irsdk_getData(int index);
```
---
## 八、Broadcast Messages(远程控制)
通过 Windows 消息 `IRSDK_BROADCASTMSG` 发送控制命令:
### 摄像机控制
```c
// 切换摄像机:按位置
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchPos, carPosition, group, camera);
// 切换摄像机:按车号
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchNum, driverNum, group, camera);
// 设置摄像机状态
irsdk_broadcastMsg(irsdk_BroadcastCamSetState, irsdk_CameraState, 0, 0);
```
### 回放控制
```c
// 设置播放速度
irsdk_broadcastMsg(irsdk_BroadcastReplaySetPlaySpeed, speed, slowMotion, 0);
// 跳转位置
irsdk_broadcastMsg(irskd_BroadcastReplaySetPlayPosition,
irsdk_RpyPos_Begin, frameHigh, frameLow);
// 搜索事件
irsdk_broadcastMsg(irsdk_BroadcastReplaySearch,
irsdk_RpySrch_NextLap, 0, 0);
// 搜索会话时间
irsdk_broadcastMsg(irsdk_BroadcastReplaySearchSessionTime,
sessionNum, sessionTimeMS_high, sessionTimeMS_low);
```
### 进站控制
```c
// 清空所有勾选
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Clear, 0);
// 加油(0 = 使用默认值)
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Fuel, addLiters);
// 换左前胎(指定胎压 kPa,0 = 默认)
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_LF, pressureKpa);
// 换胎配方
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_TC, compound);
// 快速维修
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_FR, 0);
```
### 遥测录制控制
```c
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Start, 0, 0);
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Stop, 0, 0);
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Restart, 0, 0);
```
### 聊天宏
```c
irsdk_broadcastMsg(irsdk_BroadcastChatComand, irsdk_ChatCommand_Macro, macroNum, 0);
```
---
## 九、与 F1 24 / ACC 的对比
| 特性 | iRacing | F1 24 | ACC |
|---|---|---|---|
| **协议** | 共享内存 | UDP | UDP + 共享内存 |
| **更新频率** | 60Hz(可 360Hz | 最高 60Hz | 物理步进 |
| **多车数据** | ✅ CarIdx 数组(≤64 辆车) | ✅ 22 辆车 | ✅ Broadcasting |
| **回放数据** | ✅ .ibt 文件 | ❌ | ❌ |
| **远程控制** | ✅ Broadcast Msg | ❌ | ✅ Camera/Focus |
| **写入支持** | ✅ 进站/摄像机/遥测 | ❌ | ✅ Camera/HUD |
| **变量动态性** | 每车/每会话不同 | 固定结构 | 固定结构 |
| **子 Tick 精度** | ✅ `_ST` 后缀变量 | ❌ | ❌ |
| **运动平台** | ✅ 完整扭矩/加速度 | ✅ Motion Ex | ✅ Shared Memory |
---
## 十、关键注意事项
1. **变量动态性**:iRacing 的变量列表不是固定的—不同车辆/会话中可能不同,必须先读取 `varHeader` 来确定可用的变量和偏移量
2. **只读**:所有遥测数据只读,不能通过 SDK 修改车辆参数(只能通过 Broadcast Message 控制进站选项)
3. **三缓冲**:务必在 `irsdk_waitForDataReady()` 返回后立即拷贝数据,不要直接引用共享内存
4. **Session Info 更新**:通过 `sessionInfoUpdate` 计数器检测 YAML 字符串是否变化
5. **.ibt 兼容**:磁盘遥测文件结构与 Live 相同,可用相同代码解析(多了 `irsdk_diskSubHeader`
6. **启动时序**:先调用 `irsdk_startup()` → 等待 `irsdk_isConnected()` → 等待 `SessionState >= StateRacing`
7. **360Hz 模式**:部分车辆支持 360Hz 遥测,检查 `tickRate` 字段
+5 -1
View File
@@ -17,6 +17,8 @@ games/user/my_game/
"id": "my_game", "id": "my_game",
"name": "我的游戏", "name": "我的游戏",
"parser_type": "forza", "parser_type": "forza",
"protocol": "udp",
"mode": "server",
"description": "自定义游戏遥测", "description": "自定义游戏遥测",
"author": "你的名字", "author": "你的名字",
"version": "1.0.0", "version": "1.0.0",
@@ -29,10 +31,12 @@ games/user/my_game/
| `id` | string | 是 | 唯一标识,= 文件夹名 | | `id` | string | 是 | 唯一标识,= 文件夹名 |
| `name` | string | 是 | 显示名称 | | `name` | string | 是 | 显示名称 |
| `parser_type` | string | 是 | 解析器类型标签 | | `parser_type` | string | 是 | 解析器类型标签 |
| `protocol` | string | 否 | 传输协议: `udp`(默认) / `tcp` |
| `mode` | string | 否 | TCP模式: `server`(框架监听) / `client`(插件自连) |
| `description` | string | 否 | 描述文字 | | `description` | string | 否 | 描述文字 |
| `author` | string | 否 | 作者 | | `author` | string | 否 | 作者 |
| `version` | string | 否 | 版本号 | | `version` | string | 否 | 版本号 |
| `default_port` | int | 否 | 默认 UDP 端口 (默认 20777) | | `default_port` | int | 否 | 默认端口 (UDP默认20777, TCP默认20778) |
## parser.py 规范 ## parser.py 规范
+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
+61 -13
View File
@@ -8,6 +8,12 @@ def get_parser():
class F124Parser: class F124Parser:
"""F1 24 UDP telemetry parser based on official EA SPORTS specification."""
# Packet header (29 bytes)
HEADER_SIZE = 29
CAR_TELEMETRY_SIZE = 43 # sizeof(CarTelemetryData) with padding
def game_id(self) -> str: def game_id(self) -> str:
return "f1_24" return "f1_24"
@@ -15,22 +21,64 @@ class F124Parser:
td = TelemetryData(game_id="f1_24", timestamp=time.time()) td = TelemetryData(game_id="f1_24", timestamp=time.time())
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"} td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 1289: if len(data) < 50:
return td return td
try: try:
td.speed_kmh = struct.unpack_from("<f", data, 37)[0] # Parse header
td.speed_mph = td.speed_kmh * 0.621371 packet_id = struct.unpack_from("<B", data, 6)[0]
td.rpm = struct.unpack_from("<H", data, 41)[0] player_idx = struct.unpack_from("<B", data, 27)[0]
td.max_rpm = struct.unpack_from("<H", data, 43)[0] td.raw["packet_id"] = packet_id
td.gear = struct.unpack_from("<B", data, 46)[0] & 0x0F td.raw["player_index"] = player_idx
td.throttle = struct.unpack_from("<f", data, 47)[0]
td.brake = struct.unpack_from("<f", data, 55)[0] if packet_id == 6: # Car Telemetry Packet (ID=6)
td.steering = struct.unpack_from("<B", data, 45)[0] / 127.0 offset = self.HEADER_SIZE + player_idx * self.CAR_TELEMETRY_SIZE
td.lap_number = struct.unpack_from("<B", data, 262)[0]
td.lap_time = struct.unpack_from("<f", data, 63)[0] td.speed_kmh = struct.unpack_from("<H", data, offset)[0]
td.best_lap = struct.unpack_from("<f", data, 268)[0] td.speed_mph = td.speed_kmh * 0.621371
td.fuel = struct.unpack_from("<f", data, 51)[0]
td.throttle = struct.unpack_from("<f", data, offset + 4)[0]
td.steering = struct.unpack_from("<f", data, offset + 8)[0]
td.brake = struct.unpack_from("<f", data, offset + 12)[0]
td.clutch = struct.unpack_from("<B", data, offset + 16)[0] / 100.0
td.gear = struct.unpack_from("<b", data, offset + 17)[0]
td.rpm = struct.unpack_from("<H", data, offset + 18)[0]
td.raw["drs"] = struct.unpack_from("<B", data, offset + 20)[0]
td.raw["rev_lights_percent"] = struct.unpack_from("<B", data, offset + 21)[0]
td.raw["rev_lights_bit"] = struct.unpack_from("<H", data, offset + 22)[0]
for i in range(4):
td.raw[f"tyre_surface_temp_{i}"] = struct.unpack_from("<B", data, offset + 30 + i)[0]
td.raw[f"tyre_inner_temp_{i}"] = struct.unpack_from("<B", data, offset + 34 + i)[0]
td.raw["engine_temp"] = struct.unpack_from("<H", data, offset + 38)[0]
elif packet_id == 0: # Motion Packet (ID=0)
offset = self.HEADER_SIZE + player_idx * 64 # sizeof(CarMotionData)
td.acceleration_x = struct.unpack_from("<f", data, offset + 28)[0]
td.acceleration_y = struct.unpack_from("<f", data, offset + 32)[0]
td.acceleration_z = struct.unpack_from("<f", data, offset + 36)[0]
elif packet_id == 2: # Lap Data Packet (ID=2)
offset = self.HEADER_SIZE + player_idx * 24 # sizeof(LapData)
td.last_lap = struct.unpack_from("<f", data, offset)[0]
td.lap_time = struct.unpack_from("<f", data, offset + 4)[0]
td.best_lap = struct.unpack_from("<f", data, offset + 8)[0]
elif packet_id == 7: # Car Status Packet (ID=7)
offset = self.HEADER_SIZE + player_idx * 41 # sizeof(CarStatusData)
td.raw["traction_control"] = struct.unpack_from("<B", data, offset)[0]
td.raw["anti_lock_brakes"] = struct.unpack_from("<B", data, offset + 1)[0]
td.fuel = struct.unpack_from("<f", data, offset + 2)[0] / 110.0 # normalized to 0-1
if td.fuel > 1.0:
td.fuel = min(1.0, td.fuel / 100.0)
td.max_rpm = struct.unpack_from("<H", data, offset + 26)[0]
td.raw["ers_deploy_mode"] = struct.unpack_from("<B", data, offset + 34)[0]
td.raw["ers_store_energy"] = struct.unpack_from("<f", data, offset + 36)[0]
except struct.error: except struct.error:
pass pass
+5
View File
@@ -45,6 +45,11 @@ class ForzaHorizonParser:
td.handbrake = struct.unpack_from("<B", data, 318)[0] / 255.0 td.handbrake = struct.unpack_from("<B", data, 318)[0] / 255.0
td.gear = struct.unpack_from("<B", data, 319)[0] td.gear = struct.unpack_from("<B", data, 319)[0]
td.steering = max(-1.0, min(1.0, struct.unpack_from("<b", data, 320)[0] / 127.0)) td.steering = max(-1.0, min(1.0, struct.unpack_from("<b", data, 320)[0] / 127.0))
td.raw["tire_temp_fl"] = struct.unpack_from("<f", data, 268)[0]
td.raw["tire_temp_fr"] = struct.unpack_from("<f", data, 272)[0]
td.raw["tire_temp_rl"] = struct.unpack_from("<f", data, 276)[0]
td.raw["tire_temp_rr"] = struct.unpack_from("<f", data, 280)[0]
except struct.error: except struct.error:
pass pass
+6 -18
View File
@@ -8,25 +8,13 @@ def get_parser():
class IRacingParser: class IRacingParser:
"""
iRacing uses shared memory (IRSDK Memory-Mapped File), NOT UDP.
This parser is a stub - it does NOT work with UDP input.
Requires IRSDK mmap reader (not yet implemented).
"""
def game_id(self) -> str: def game_id(self) -> str:
return "iracing" return "iracing"
def parse(self, data: bytes, addr: tuple) -> TelemetryData: def parse(self, data: bytes, addr: tuple) -> TelemetryData:
td = TelemetryData(game_id="iracing", timestamp=time.time()) return TelemetryData(game_id="iracing", timestamp=time.time(), raw={"note": "iRacing uses shared memory, not UDP"})
td.raw = {"raw_hex": data.hex(), "length": len(data), "addr": f"{addr[0]}:{addr[1]}"}
if len(data) < 100:
return td
try:
td.speed_mph = struct.unpack_from("<f", data, 36)[0]
td.speed_kmh = td.speed_mph * 1.60934
td.rpm = struct.unpack_from("<f", data, 48)[0]
td.gear = struct.unpack_from("<i", data, 56)[0]
td.throttle = struct.unpack_from("<f", data, 4)[0]
td.brake = struct.unpack_from("<f", data, 8)[0]
td.steering = struct.unpack_from("<f", data, 0)[0]
except struct.error:
pass
return td
+9
View File
@@ -35,6 +35,7 @@ class DashboardTheme:
updated_at: str = "" updated_at: str = ""
is_builtin: bool = False is_builtin: bool = False
dir_path: str = "" dir_path: str = ""
preview_ext: str = ""
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -51,6 +52,7 @@ class DashboardTheme:
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
"is_builtin": self.is_builtin, "is_builtin": self.is_builtin,
"preview_ext": self.preview_ext,
} }
@classmethod @classmethod
@@ -59,6 +61,12 @@ class DashboardTheme:
with open(manifest_path, "r", encoding="utf-8") as f: with open(manifest_path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
config = data.get("config", {}) config = data.get("config", {})
dir_path = manifest_path.parent
preview_ext = ""
for ext in ["jpg", "jpeg", "png", "webp", "gif"]:
if (dir_path / f"preview.{ext}").exists():
preview_ext = ext
break
return cls( return cls(
id=data.get("id", manifest_path.parent.name), id=data.get("id", manifest_path.parent.name),
name=data.get("name", manifest_path.parent.name), name=data.get("name", manifest_path.parent.name),
@@ -74,6 +82,7 @@ class DashboardTheme:
updated_at=data.get("updated_at", ""), updated_at=data.get("updated_at", ""),
is_builtin=is_builtin, is_builtin=is_builtin,
dir_path=str(manifest_path.parent), dir_path=str(manifest_path.parent),
preview_ext=preview_ext,
) )
except Exception as e: except Exception as e:
logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e) logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e)
+50 -20
View File
@@ -84,34 +84,51 @@ class SceneManager:
def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]: def list_all(self, game_id: str | None = None) -> list[dict[str, Any]]:
scenes = [] scenes = []
for f in SCENES_DIR.glob("*.json"): for item in SCENES_DIR.iterdir():
if not item.is_dir():
continue
f = item / "manifest.json"
if not f.exists():
continue
try: try:
with open(f, "r", encoding="utf-8") as fp: with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp) data = json.load(fp)
scene = Scene.from_dict(data) scene = Scene.from_dict(data)
if game_id is None or scene.game_id == game_id: if game_id is None or scene.game_id == game_id:
scenes.append(scene.to_dict()) d = scene.to_dict()
for ext in ["jpg","jpeg","png","webp","gif"]:
if (item / f"preview.{ext}").exists():
d["preview_ext"] = ext
break
scenes.append(d)
except Exception as e: except Exception as e:
logger.error("Failed to load scene %s: %s", f.name, e) logger.error("Failed to load scene %s: %s", item.name, e)
return scenes return scenes
def get(self, scene_id: str) -> dict[str, Any] | None: def get(self, scene_id: str) -> dict[str, Any] | None:
filepath = SCENES_DIR / f"{scene_id}.json" item = SCENES_DIR / scene_id
if not filepath.exists(): f = item / "manifest.json"
if not f.exists():
return None return None
try: try:
with open(filepath, "r", encoding="utf-8") as f: with open(f, "r", encoding="utf-8") as fp:
data = json.load(f) data = json.load(fp)
return Scene.from_dict(data).to_dict() d = Scene.from_dict(data).to_dict()
for ext in ["jpg","jpeg","png","webp","gif"]:
if (item / f"preview.{ext}").exists():
d["preview_ext"] = ext
break
return d
except Exception as e: except Exception as e:
logger.error("Failed to read scene %s: %s", scene_id, e) logger.error("Failed to read scene %s: %s", scene_id, e)
return None return None
def save(self, scene: Scene) -> bool: def save(self, scene: Scene) -> bool:
scene.updated_at = datetime.now().isoformat() scene.updated_at = datetime.now().isoformat()
filepath = SCENES_DIR / f"{scene.id}.json" item = SCENES_DIR / scene.id
item.mkdir(parents=True, exist_ok=True)
try: try:
with open(filepath, "w", encoding="utf-8") as f: with open(item / "manifest.json", "w", encoding="utf-8") as f:
json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False) json.dump(scene.to_dict(), f, indent=2, ensure_ascii=False)
logger.info("Scene saved: %s", scene.id) logger.info("Scene saved: %s", scene.id)
return True return True
@@ -120,32 +137,45 @@ class SceneManager:
return False return False
def delete(self, scene_id: str) -> bool: def delete(self, scene_id: str) -> bool:
filepath = SCENES_DIR / f"{scene_id}.json" import shutil
if filepath.exists(): item = SCENES_DIR / scene_id
filepath.unlink() if item.exists():
shutil.rmtree(item)
logger.info("Scene deleted: %s", scene_id) logger.info("Scene deleted: %s", scene_id)
return True return True
return False return False
def export_scene_zip(self, scene_id: str) -> bytes | None: def export_scene_zip(self, scene_id: str) -> bytes | None:
filepath = SCENES_DIR / f"{scene_id}.json" item = SCENES_DIR / scene_id
if not filepath.exists(): if not item.exists():
return None return None
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(filepath, "scene.json") for f in sorted(item.rglob("*")):
if f.is_file():
zf.write(f, f.relative_to(item))
logger.info("Scene exported as zip: %s", scene_id) logger.info("Scene exported as zip: %s", scene_id)
return buf.getvalue() return buf.getvalue()
def import_scene_zip(self, zip_data: bytes) -> bool: def import_scene_zip(self, zip_data: bytes) -> bool:
try: try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf: with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if "scene.json" not in zf.namelist(): if "manifest.json" not in zf.namelist():
logger.error("No scene.json found in zip") logger.error("No manifest.json found in zip")
return False return False
data = json.loads(zf.read("scene.json").decode('utf-8')) data = json.loads(zf.read("manifest.json").decode('utf-8'))
scene = Scene.from_dict(data) scene = Scene.from_dict(data)
return self.save(scene) item = SCENES_DIR / scene.id
if item.exists():
import shutil; shutil.rmtree(item)
item.mkdir(parents=True)
for name in zf.namelist():
if name.endswith('/'): continue
dest = item / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(name))
logger.info("Scene imported from zip: %s", scene.id)
return True
except Exception as e: except Exception as e:
logger.error("Failed to import scene zip: %s", e) logger.error("Failed to import scene zip: %s", e)
return False return False
+1
View File
@@ -4,3 +4,4 @@ websockets==14.1
aiofiles==24.1.0 aiofiles==24.1.0
jinja2==3.1.4 jinja2==3.1.4
python-multipart==0.0.18 python-multipart==0.0.18
httpx==0.28.1
+506
View File
@@ -72,6 +72,18 @@ async def api_stop_telemetry():
return {"ok": True, "running": telemetry_listener.is_running} return {"ok": True, "running": telemetry_listener.is_running}
@router.post("/test-mode/start")
async def api_start_test_mode():
telemetry_listener.start_test_mode()
return {"ok": True, "testing": True}
@router.post("/test-mode/stop")
async def api_stop_test_mode():
telemetry_listener.stop_test_mode()
return {"ok": True, "testing": False}
@router.get("/telemetry/latest") @router.get("/telemetry/latest")
async def api_latest_telemetry(): async def api_latest_telemetry():
latest = telemetry_listener.latest_data latest = telemetry_listener.latest_data
@@ -313,6 +325,87 @@ async def api_reload_game_plugins():
return {"ok": True, "count": len(game_plugin_manager.list_all())} return {"ok": True, "count": len(game_plugin_manager.list_all())}
# ---- Logs ----
@router.get("/logs")
async def api_get_logs(lines: int = 50):
from pathlib import Path
log_file = Path(__file__).resolve().parent.parent / "logs" / "turbosu.log"
if not log_file.exists():
return {"lines": []}
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
return {"lines": [l.rstrip() for l in all_lines[-lines:]]}
except Exception:
return {"lines": []}
# ---- Full Backup (.tsb) ----
@router.get("/backup/export")
async def api_export_backup():
import io, zipfile
from fastapi.responses import Response
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
base = Path(__file__).resolve().parent.parent
data_dir = base / "data"
if data_dir.exists():
for f in data_dir.rglob("*"):
if f.is_file() and "__pycache__" not in str(f):
zf.write(f, f.relative_to(base))
dash_dir = base / "dashboards"
if dash_dir.exists():
for f in dash_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(base))
games_dir = base / "games" / "user"
if games_dir.exists():
for f in games_dir.rglob("*"):
if f.is_file():
zf.write(f, f.relative_to(base))
return Response(content=buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": "attachment; filename=turbosu_backup.tsb"})
@router.post("/backup/import")
async def api_import_backup(file: UploadFile = File(...)):
if not file.filename or not file.filename.endswith('.tsb'):
raise HTTPException(400, "Only .tsb files are accepted")
import io, zipfile, shutil
zip_data = await file.read()
base = Path(__file__).resolve().parent.parent
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
for name in zf.namelist():
if name.endswith('/') or '__pycache__' in name:
continue
dest = base / name
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(zf.read(name))
from config.settings import reload_config
reload_config()
from server.game_manager import game_plugin_manager
game_plugin_manager.reload()
return {"ok": True, "message": "备份已恢复,请重启服务"}
except Exception as e:
return {"ok": False, "message": str(e)}
# ---- Restart ----
@router.post("/restart")
async def api_restart():
import os, sys, atexit
logger.info("Restart requested via API")
atexit.register(lambda: os.execv(sys.executable, [sys.executable] + sys.argv))
sys.exit(0)
# ---- Data Forwarding ---- # ---- Data Forwarding ----
@router.get("/forward") @router.get("/forward")
async def api_list_forward_targets(): async def api_list_forward_targets():
@@ -329,3 +422,416 @@ async def api_update_forward_targets(data: dict[str, Any]):
save_config(cfg) save_config(cfg)
telemetry_listener.forwarder.reload_targets() telemetry_listener.forwarder.reload_targets()
return {"ok": True, "active": telemetry_listener.forwarder.active} return {"ok": True, "active": telemetry_listener.forwarder.active}
# ---- Record & Playback ----
from server.recorder import TelemetryRecorder, TelemetryPlayer
_recorder = None
_player = None
def _get_recorder():
global _recorder
if _recorder is None: _recorder = TelemetryRecorder(telemetry_listener)
return _recorder
def _get_player():
global _player
if _player is None: _player = TelemetryPlayer(telemetry_listener)
return _player
@router.post("/record/start")
async def api_start_record():
_get_recorder().start()
return {"ok": True, "recording": True}
@router.post("/record/stop")
async def api_stop_record():
_get_recorder().stop()
return {"ok": True, "recording": False}
@router.get("/record/status")
async def api_record_status():
return {"recording": _get_recorder().is_recording, "playing": _get_player().is_playing}
@router.get("/recordings")
async def api_list_recordings():
return TelemetryPlayer.list_recordings()
@router.post("/playback/start")
async def api_start_playback(data: dict):
filename = data.get("filename", "")
if not filename: raise HTTPException(400, "filename required")
_get_player().start(filename)
return {"ok": True, "playing": True}
@router.post("/playback/stop")
async def api_stop_playback():
_get_player().stop()
return {"ok": True, "playing": False}
@router.get("/recording/{filename}/export")
async def api_export_recording(filename: str):
from fastapi.responses import FileResponse
from server.recorder import RECORDINGS_DIR
path = RECORDINGS_DIR / filename
if not path.exists():
raise HTTPException(404, "Recording not found")
return FileResponse(path, media_type="application/octet-stream", filename=filename)
@router.delete("/recording/{filename}/delete")
async def api_delete_recording(filename: str):
from server.recorder import RECORDINGS_DIR
path = RECORDINGS_DIR / filename
if path.exists(): path.unlink()
return {"ok": True}
# ---- Market Proxy ----
@router.get("/market/dashboards")
async def api_market_list():
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=50")
return resp.json()
except Exception:
return {"items": []}
@router.post("/market/auth")
async def api_market_auth(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{url}/api/collections/users/auth-with-password",
json={"identity": data.get("email", data.get("identity","")), "password": data.get("password")}
)
result = resp.json()
return result
except Exception as e:
return {"message": "服务器连接失败: " + str(e), "token": None}
@router.post("/market/register")
async def api_market_register(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{url}/api/collections/users/records",
json={
"email": data.get("email"),
"password": data.get("password"),
"passwordConfirm": data.get("passwordConfirm"),
"name": data.get("name", ""),
}
)
return resp.json()
except Exception as e:
return {"error": str(e)}
@router.post("/market/upload")
async def api_market_upload(file: UploadFile = File(...), name: str = "", category: str = "", author: str = "", token: str = ""):
import httpx, io, zipfile, json as _json
# Validate .tsd
if not file.filename or not file.filename.endswith('.tsd'):
return {"error": "仅支持 .tsd 文件"}
try:
zip_data = await file.read()
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
if 'manifest.json' not in names:
return {"error": "无效的仪表盘文件:缺少 manifest.json"}
if 'index.html' not in names:
return {"error": "无效的仪表盘文件:缺少 index.html"}
manifest = _json.loads(zf.read('manifest.json').decode('utf-8'))
if not manifest.get('id'):
import uuid
manifest['id'] = str(uuid.uuid4())
if not manifest.get('name'):
return {"error": "manifest.json 缺少 name"}
import hashlib, io as _io
if not token:
return {"error": "需要登录"}
user_prefix = hashlib.md5(token.encode()).hexdigest()[:6]
manifest_id = f"{user_prefix}__{manifest['id']}"
manifest['id'] = manifest_id
# Rewrite zip with updated manifest.json
new_zip = _io.BytesIO()
with zipfile.ZipFile(new_zip, 'w', zipfile.ZIP_DEFLATED) as zf_out:
with zipfile.ZipFile(_io.BytesIO(zip_data), 'r') as zf_in:
for name in zf_in.namelist():
if name == 'manifest.json':
zf_out.writestr(name, _json.dumps(manifest, indent=2, ensure_ascii=False))
else:
zf_out.writestr(name, zf_in.read(name))
zip_data = new_zip.getvalue()
actual_name = name or manifest.get('name', file.filename.replace('.tsd', ''))
actual_category = category or manifest.get('category', 'community')
actual_author = author or manifest.get('author', 'Unknown')
actual_version = manifest.get('version', '1.0.0')
supported = manifest.get('supported_games', 'all')
icon = manifest.get('config', {}).get('icon', '📦')
desc = manifest.get('description', '')
except zipfile.BadZipFile:
return {"error": "无效的 zip 文件"}
except Exception as e:
return {"error": f"文件解析失败: {str(e)}"}
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/dashboards/records",
headers={"Authorization": f"Bearer {token}"},
data={
"name": actual_name, "category": actual_category, "author": actual_author,
"version": actual_version, "supported_games": supported,
"icon": icon, "description": desc, "dashb_pending": "true",
"manifest_id": manifest_id,
},
files={"file": (file.filename, zip_data, file.content_type)},
)
return resp.json()
except Exception as e:
return {"error": str(e)}
@router.get("/market/install")
async def api_market_install(id: str = "", filename: str = ""):
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(f"{url}/api/files/dashboards/{id}/{filename}")
zip_data = resp.content
from models.dashboard import dashboard_manager
ok = dashboard_manager.import_theme_zip(zip_data)
if ok:
try:
async with httpx.AsyncClient(timeout=5) as client2:
# Get current downloads
r = await client2.get(f"{url}/api/collections/dashboards/records/{id}")
current = r.json().get("downloads", 0) or 0
# Increment
await client2.patch(
f"{url}/api/collections/dashboards/records/{id}",
json={"downloads": current + 1}
)
except Exception:
pass
return {"ok": ok}
except Exception as e:
return {"ok": False, "error": str(e)}
# ---- Market Scenes ----
@router.get("/market/scenes")
async def api_market_scenes():
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=50")
return resp.json()
except Exception:
return {"items": []}
@router.post("/market/scenes/upload")
async def api_market_upload_scene(file: UploadFile = File(...), token: str = ""):
import io, zipfile, json as _json
if not file.filename or not file.filename.endswith('.tss'):
return {"error": "仅支持 .tss 文件"}
try:
zip_data = await file.read()
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if 'manifest.json' not in zf.namelist():
return {"error": "缺少 manifest.json"}
scene = _json.loads(zf.read('manifest.json').decode('utf-8'))
if not scene.get('name'):
return {"error": "manifest.json 缺少 name"}
deps = set()
for canvas in scene.get('canvases', []):
for p in canvas.get('placements', []):
if p.get('dashboard_id'):
deps.add(p['dashboard_id'])
except zipfile.BadZipFile:
return {"error": "无效的 zip 文件"}
except Exception as e:
return {"error": str(e)}
import httpx as _httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with _httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/scenes/records",
headers={"Authorization": f"Bearer {token}"},
data={"name": scene.get('name'), "description": scene.get('description',''), "game_id": scene.get('game_id',''), "author": scene.get('author','') or "Unknown", "dashb_pending": True},
files={"file": (file.filename, zip_data, file.content_type)},
)
result = resp.json()
result["dependencies"] = list(deps)
return result
except Exception as e:
return {"error": str(e)}
@router.get("/market/scenes/install")
async def api_market_install_scene(id: str = "", filename: str = ""):
import httpx, io, zipfile, json as _json
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
resp = await client.get(f"{url}/api/files/scenes/{id}/{filename}")
zip_data = resp.content
missing = []
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if 'manifest.json' in zf.namelist():
scene = _json.loads(zf.read('manifest.json').decode('utf-8'))
from models.dashboard import dashboard_manager
for canvas in scene.get('canvases', []):
for p in canvas.get('placements', []):
did = p.get('dashboard_id', '')
if did and not dashboard_manager.get(did):
missing.append(did)
from models.scene import scene_manager
ok = scene_manager.import_scene_zip(zip_data)
return {"ok": ok, "missing_dashboards": missing}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.get("/market/mine")
async def api_market_mine(token: str = ""):
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
result = {"dashboards": [], "scenes": []}
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.get(f"{url}/api/collections/dashboards/records?sort=-name&perPage=100",
headers={"Authorization": f"Bearer {token}"})
result["dashboards"] = r.json().get("items", [])
r = await client.get(f"{url}/api/collections/scenes/records?sort=-name&perPage=100",
headers={"Authorization": f"Bearer {token}"})
result["scenes"] = r.json().get("items", [])
except Exception:
pass
return result
@router.post("/market/delete")
async def api_market_delete(data: dict):
import httpx
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
try:
async with httpx.AsyncClient(timeout=10) as client:
collection = data.get("collection", "dashboards")
record_id = data.get("id", "")
token = data.get("token", "")
resp = await client.delete(
f"{url}/api/collections/{collection}/records/{record_id}",
headers={"Authorization": f"Bearer {token}"}
)
return {"ok": resp.status_code < 400}
except Exception as e:
return {"ok": False, "error": str(e)}
@router.post("/market/publish-local")
async def api_market_publish_local(data: dict):
import io, zipfile, json as _json
token = data.get("token", "")
dash_id = data.get("id", "")
pub_type = data.get("type", "dashboard")
if not token:
return {"error": "需要登录"}
try:
from models.dashboard import dashboard_manager
from models.scene import scene_manager
if pub_type == "scene":
scene = scene_manager.get(dash_id)
if not scene:
return {"error": "场景不存在"}
if scene.get("is_builtin"):
return {"error": "不能发布内置场景"}
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr("manifest.json", _json.dumps(scene, indent=2, ensure_ascii=False))
zip_data = buf.getvalue()
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/scenes/records",
headers={"Authorization": f"Bearer {token}"},
data={"name": scene.get("name",""), "description": scene.get("description",""),
"game_id": scene.get("game_id",""), "author": scene.get("author","") or "Unknown",
"dashb_pending": "true", "manifest_id": scene.get("id","")},
files={"file": (f"{dash_id}.tss", zip_data, "application/zip")},
)
return resp.json()
else:
theme_dir = dashboard_manager.get_dir(dash_id)
if not theme_dir:
return {"error": "仪表盘不存在"}
theme = dashboard_manager.get(dash_id)
if not theme or theme.get("is_builtin"):
return {"error": "不能发布内置或市场仪表盘"}
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in sorted(theme_dir.rglob('*')):
if f.is_file():
zf.write(f, f.relative_to(theme_dir))
zip_data = buf.getvalue()
cfg = get_config()
url = cfg.get("market_url", "https://turbosu.yeij.top")
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{url}/api/collections/dashboards/records",
headers={"Authorization": f"Bearer {token}"},
data={"name": theme.get("name",""), "category": theme.get("category",""),
"author": theme.get("author",""), "version": theme.get("version","1.0"),
"dashb_pending": "true", "manifest_id": theme.get("id",""),
"icon": theme.get("icon",""), "supported_games": theme.get("supported_games","all")},
files={"file": (f"{dash_id}.tsd", zip_data, "application/zip")},
)
return resp.json()
except Exception as e:
return {"error": str(e)}
+22 -4
View File
@@ -68,11 +68,11 @@ class GamePluginManager:
USER_DIR.mkdir(parents=True, exist_ok=True) USER_DIR.mkdir(parents=True, exist_ok=True)
self._plugins: dict[str, GamePlugin] = {} self._plugins: dict[str, GamePlugin] = {}
self._parser_cache: dict[str, Any] = {} self._parser_cache: dict[str, Any] = {}
self._scanned = False
self._discover() self._discover()
def _discover(self): def _discover(self):
self._plugins.clear() self._plugins.clear()
for d in [BUILTIN_DIR, USER_DIR]: for d in [BUILTIN_DIR, USER_DIR]:
if not d.exists(): if not d.exists():
continue continue
@@ -84,14 +84,18 @@ class GamePluginManager:
gp = GamePlugin.from_manifest(manifest, is_builtin) gp = GamePlugin.from_manifest(manifest, is_builtin)
if gp: if gp:
self._plugins[gp.id] = gp self._plugins[gp.id] = gp
logger.debug("Discovered game plugin: %s", gp.id) self._scanned = True
def _ensure_scanned(self):
if not self._scanned:
self._discover()
def list_all(self) -> list[dict[str, Any]]: def list_all(self) -> list[dict[str, Any]]:
self._discover() self._ensure_scanned()
return [p.to_dict() for p in self._plugins.values()] return [p.to_dict() for p in self._plugins.values()]
def get(self, plugin_id: str) -> GamePlugin | None: def get(self, plugin_id: str) -> GamePlugin | None:
self._discover() self._ensure_scanned()
return self._plugins.get(plugin_id) return self._plugins.get(plugin_id)
def get_parser(self, plugin_id: str) -> Any | None: def get_parser(self, plugin_id: str) -> Any | None:
@@ -130,6 +134,20 @@ class GamePluginManager:
self._discover() self._discover()
self._parser_cache.clear() self._parser_cache.clear()
def remove_plugin(self, plugin_id: str) -> bool:
self._discover()
gp = self._plugins.get(plugin_id)
if gp and gp.is_builtin:
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
return False
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
self._discover()
logger.info("Plugin removed: %s", plugin_id)
return True
return False
def install_plugin_zip(self, zip_data: bytes) -> bool: def install_plugin_zip(self, zip_data: bytes) -> bool:
try: try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf: with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
+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)]
+83 -16
View File
@@ -8,6 +8,7 @@ from typing import Any, Callable
from config.settings import get_config from config.settings import get_config
from server.telemetry.parsers import PARSER_MAP, BaseParser from server.telemetry.parsers import PARSER_MAP, BaseParser
from server.telemetry.data import TelemetryData from server.telemetry.data import TelemetryData
from server.telemetry.tcp_listener import TCPListener
from server.game_manager import game_plugin_manager from server.game_manager import game_plugin_manager
from utils.logger import get_logger from utils.logger import get_logger
@@ -50,7 +51,9 @@ class DataForwarder:
class TelemetryListener: class TelemetryListener:
def __init__(self): def __init__(self):
self._transport: asyncio.DatagramTransport | None = None self._transport: asyncio.DatagramTransport | None = None
self._tcp: TCPListener | None = None
self._running = False self._running = False
self._current_protocol = "udp"
self._callbacks: list[Callable[[TelemetryData], None]] = [] self._callbacks: list[Callable[[TelemetryData], None]] = []
self._parser: BaseParser | None = None self._parser: BaseParser | None = None
self._parser_cache: dict[str, BaseParser] = {} self._parser_cache: dict[str, BaseParser] = {}
@@ -90,33 +93,93 @@ class TelemetryListener:
host = cfg.get("telemetry_host", "0.0.0.0") host = cfg.get("telemetry_host", "0.0.0.0")
port = cfg.get("telemetry_port", 20777) port = cfg.get("telemetry_port", 20777)
loop = asyncio.get_event_loop() selected_game = cfg.get("selected_game_id")
try: if selected_game:
self._transport, _ = await loop.create_datagram_endpoint( self.set_parser_for_game(selected_game)
lambda: _TelemetryProtocol(self), gp = game_plugin_manager.get(selected_game)
local_addr=(host, port), if gp and gp.manifest_path:
) import json as _json
self._running = True try:
self.forwarder.reload_targets() with open(f"{gp.manifest_path}/manifest.json", "r") as f:
manifest = _json.load(f)
self._current_protocol = manifest.get("protocol", "udp")
except Exception:
self._current_protocol = "udp"
selected_game = cfg.get("selected_game_id") if self._current_protocol == "tcp":
import json as _json
mode = "server"
tcp_path = "/"
if selected_game: if selected_game:
self.set_parser_for_game(selected_game) gp = game_plugin_manager.get(selected_game)
if gp and gp.manifest_path:
try:
with open(f"{gp.manifest_path}/manifest.json", "r") as f:
m = _json.load(f)
mode = m.get("mode", "server")
tcp_path = m.get("path", "/")
except Exception:
pass
logger.info("Telemetry listener started on %s:%d [game=%s]", if mode == "server":
host, port, selected_game or "none") self._tcp = TCPListener(self._handle_packet)
return True ok = await self._tcp.start(host, port)
except Exception as e: if ok:
logger.error("Failed to start telemetry listener: %s", e) self._running = True
return False self.forwarder.reload_targets()
logger.info("TCP server started on %s:%d [game=%s]", host, port, selected_game or "none")
return ok
else:
# TCP client mode: plugin handles its own connection
logger.info("TCP client mode - plugin handles connection [game=%s]", selected_game or "none")
self._running = True
return True
else:
loop = asyncio.get_event_loop()
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: _TelemetryProtocol(self),
local_addr=(host, port),
)
self._running = True
self.forwarder.reload_targets()
logger.info("UDP listener started on %s:%d [game=%s]", host, port, selected_game or "none")
return True
except Exception as e:
logger.error("Failed to start UDP listener: %s", e)
return False
def stop(self): def stop(self):
self._running = False self._running = False
self.stop_test_mode()
if self._transport: if self._transport:
self._transport.close() self._transport.close()
self._transport = None self._transport = None
if self._tcp:
asyncio.ensure_future(self._tcp.stop())
self._tcp = None
if self.forwarder._sock:
self.forwarder._sock.close()
self.forwarder._sock = None
logger.info("Telemetry listener stopped") logger.info("Telemetry listener stopped")
@property
def is_testing(self) -> bool:
from server.test_mode import get_generator
gen = get_generator()
return gen is not None and gen.running
def start_test_mode(self):
from server.test_mode import get_generator
gen = get_generator(self)
gen.start()
def stop_test_mode(self):
from server.test_mode import get_generator
gen = get_generator()
if gen is not None and gen.running:
gen.stop()
def _get_parser(self, key: str) -> BaseParser: def _get_parser(self, key: str) -> BaseParser:
if key not in self._parser_cache: if key not in self._parser_cache:
cls = PARSER_MAP.get(key) cls = PARSER_MAP.get(key)
@@ -130,6 +193,10 @@ class TelemetryListener:
self.forwarder.forward(data) self.forwarder.forward(data)
from server.api import _recorder
if _recorder and _recorder.is_recording:
_recorder.write_packet(data)
td = None td = None
if self._parser: if self._parser:
td = self._parser.parse(data, addr) td = self._parser.parse(data, addr)
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import asyncio
import socket
import struct
import time
from typing import Any, Callable
from utils.logger import get_logger
logger = get_logger(__name__)
class TCPListener:
def __init__(self, handle_packet):
self._server: asyncio.Server | None = None
self._handle_packet = handle_packet
self._running = False
@property
def is_running(self) -> bool:
return self._running
async def start(self, host: str, port: int) -> bool:
if self._running:
await self.stop()
try:
self._server = await asyncio.start_server(
self._on_client_connected, host, port
)
self._running = True
logger.info("TCP listener started on %s:%d", host, port)
return True
except Exception as e:
logger.error("Failed to start TCP listener: %s", e)
return False
async def stop(self):
self._running = False
if self._server:
self._server.close()
await self._server.wait_closed()
self._server = None
logger.info("TCP listener stopped")
async def _on_client_connected(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
addr = writer.get_extra_info('peername')
logger.debug("TCP client connected: %s", addr)
try:
while self._running:
data = await reader.read(65536)
if not data:
break
self._handle_packet(data, addr)
except Exception as e:
logger.debug("TCP client disconnected: %s - %s", addr, e)
finally:
writer.close()
await writer.wait_closed()
+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 .nav-label,
.sidebar.collapsed .sidebar-title, .sidebar.collapsed .sidebar-title,
.sidebar.collapsed .sidebar-logo,
.sidebar.collapsed .collapse-arrow { .sidebar.collapsed .collapse-arrow {
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
transition: opacity var(--transition-fast), visibility var(--transition-fast); width: 0;
overflow: hidden;
}
.sidebar.collapsed .sidebar-toggle-btn {
position: absolute;
top: 14px;
left: 8px;
} }
.sidebar-header { .sidebar-header {
display: flex; display: flex;
@@ -493,6 +500,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: move; cursor: move;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
@@ -500,7 +508,24 @@
background: var(--accent-container); background: var(--accent-container);
min-width: 80px; min-width: 80px;
min-height: 50px; min-height: 50px;
user-select: none;
overflow: hidden;
} }
.ep-icon { font-size: 24px; }
.ep-name { font-size: 11px; color: var(--text-secondary); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90%; }
.ep-actions { position: absolute; top: 2px; right: 2px; }
.editor-dash-item:active { opacity: 0.7; }
.forward-toggle input { margin-right: 4px; }
/* HyperOS Toggle Switch */
.switch-label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.switch-label input { display: none; }
.switch-track { position: relative; width: 44px; height: 26px; background: var(--bg-tertiary); border-radius: 13px; border: 1px solid var(--border-normal); transition: background .2s, border-color .2s; flex-shrink: 0; }
.switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform .2s; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
.switch-label input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
.switch-label input:checked + .switch-track::after { transform: translateX(18px); }
/* ===== Debug Page ===== */ /* ===== Debug Page ===== */
.debug-container { .debug-container {
@@ -683,6 +708,16 @@ code {
.sidebar { .sidebar {
display: none; display: none;
position: fixed;
left: 0; top: 0;
width: var(--sidebar-width);
height: 100vh;
z-index: 200;
box-shadow: none;
}
.sidebar.mobile-open {
display: flex;
box-shadow: 4px 0 24px rgba(0,0,0,.3);
} }
.main-content { .main-content {
@@ -693,6 +728,9 @@ code {
height: 48px; height: 48px;
padding: 0 12px; padding: 0 12px;
} }
#mobile-menu-btn {
display: flex !important;
}
.topbar-logo-title.hidden { .topbar-logo-title.hidden {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@@ -861,3 +899,11 @@ code {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* ---- Settings Sub-Sidebar ---- */
.settings-layout { display: flex; gap: 20px; height: 100%; }
.settings-nav { width: 130px; flex-shrink: 0; overflow-y: auto; position: sticky; top: 0; padding-top: 8px; border-right: 1px solid var(--border-subtle); }
.settings-nav-item { display: block; padding: 8px 12px; border-radius: var(--radius-md); color: var(--text-secondary); text-decoration: none; font-size: 13px; transition: all var(--transition-fast); margin-bottom: 2px; }
.settings-nav-item:hover { background: var(--bg-glass-hover); color: var(--text-primary); }
.settings-nav-item.active { background: var(--accent-light); color: var(--accent); font-weight: 500; }
.settings-main { flex: 1; overflow-y: auto; min-width: 0; }
+2
View File
@@ -126,6 +126,8 @@ html, body {
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
transition: background var(--transition-normal), color var(--transition-normal); transition: background var(--transition-normal), color var(--transition-normal);
} }
+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 = { const API = {
_base: '/api', _base: '/api',
async get(path) { async _req(method, path, body) {
const res = await fetch(`${this._base}${path}`); const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
const res = await fetch(`${this._base}${path}`, opts);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path} ${res.status}: ${text.slice(0, 200)}`);
}
return res.json(); return res.json();
}, },
async post(path, data) { async get(path) { return this._req('GET', path); },
const res = await fetch(`${this._base}${path}`, { async post(path, data) { return this._req('POST', path, data); },
method: 'POST', async put(path, data) { return this._req('PUT', path, data); },
headers: { 'Content-Type': 'application/json' }, async del(path) { return this._req('DELETE', path); },
body: JSON.stringify(data),
});
return res.json();
},
async put(path, data) {
const res = await fetch(`${this._base}${path}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return res.json();
},
async del(path) {
const res = await fetch(`${this._base}${path}`, { method: 'DELETE' });
return res.json();
},
async getStatus() { return this.get('/status'); }, async getStatus() { return this.get('/status'); },
async getConfig() { return this.get('/config'); }, async getConfig() { return this.get('/config'); },
+4
View File
@@ -1,4 +1,5 @@
(function () { (function () {
Lang.init();
Theme.init(); Theme.init();
WS.init(); WS.init();
Sidebar.init(); Sidebar.init();
@@ -7,10 +8,13 @@
PageDashboard.init(); PageDashboard.init();
PageScene.init(); PageScene.init();
PageDebug.init(); PageDebug.init();
PageRecord.init();
PageMarket.init();
PageSettings.init(); PageSettings.init();
Router.init(); Router.init();
document.getElementById('theme-toggle').addEventListener('click', () => Theme.toggle()); document.getElementById('theme-toggle').addEventListener('click', () => Theme.toggle());
document.getElementById('lang-toggle').addEventListener('click', () => Lang.toggle());
console.log('%cTurboSu %cReady', console.log('%cTurboSu %cReady',
'color:#667eea;font-size:16px;font-weight:bold;', 'color:#667eea;font-size:16px;font-weight:bold;',
+12 -4
View File
@@ -3,6 +3,7 @@ const Sidebar = {
_gameListEl: null, _gameListEl: null,
_gameToggleEl: null, _gameToggleEl: null,
_gameLabelEl: null, _gameLabelEl: null,
_gameData: [],
init() { init() {
this._el = document.getElementById('sidebar'); this._el = document.getElementById('sidebar');
@@ -44,10 +45,11 @@ const Sidebar = {
async _loadGames() { async _loadGames() {
const games = await API.getGames(); const games = await API.getGames();
this._gameData = games;
this._gameListEl.innerHTML = ''; this._gameListEl.innerHTML = '';
if (!Array.isArray(games) || games.length === 0) { if (!Array.isArray(games) || games.length === 0) {
this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">暂无可用的游戏插件</div>'; this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">' + Lang.t('noGamePlugins') + '</div>';
return; return;
} }
@@ -75,16 +77,22 @@ const Sidebar = {
return; return;
} }
} }
this._gameLabelEl.textContent = '选择游戏'; this._gameLabelEl.textContent = Lang.t('selectGame');
}, },
async _selectGame(gameId) { async _selectGame(gameId) {
await API.updateConfig({ selected_game_id: gameId }); await API.updateConfig({ selected_game_id: gameId });
const cfg = await API.getConfig();
if (!cfg.use_unified_port) {
const gamePort = (cfg.game_ports || {})[gameId] || this._gameData.find(g => g.id === gameId)?.default_port || 20777;
await API.updateConfig({ telemetry_port: gamePort });
}
await API.stopTelemetry();
await API.startTelemetry(); await API.startTelemetry();
this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active')); this._gameListEl.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
const items = this._gameListEl.querySelectorAll('.nav-item'); const items = this._gameListEl.querySelectorAll('.nav-item');
items.forEach(item => { items.forEach((item, idx) => {
if (item.textContent.trim()) item.classList.add('active'); if (this._gameData[idx] && this._gameData[idx].id === gameId) item.classList.add('active');
}); });
const games = await API.getGames(); const games = await API.getGames();
this._updateGameLabel(gameId, games); this._updateGameLabel(gameId, games);
+12 -4
View File
@@ -3,12 +3,20 @@ const Topbar = {
init() { init() {
this._statusEl = document.getElementById('connection-status'); this._statusEl = document.getElementById('connection-status');
this._statusEl.style.cursor = 'pointer';
this._statusEl.addEventListener('click', () => {
if (!WS.connected) {
WS._reconnectDelay = 500;
WS._connect();
Toast.show(Lang.t('loading'), 'info', 1500);
}
});
WS.on('status', (status) => { WS.on('status', (status) => {
this._statusEl.className = `status-indicator ${status}`; this._statusEl.className = `status-indicator ${status}`;
this._statusEl.querySelector('.status-text').textContent = this._statusEl.querySelector('.status-text').textContent =
status === 'connected' ? '已连接' : status === 'connected' ? Lang.t('connected') :
status === 'error' ? '连接错误' : '未连接'; status === 'error' ? Lang.t('connectionError') : Lang.t('disconnected');
}); });
setInterval(() => this._refreshStatus(), 5000); setInterval(() => this._refreshStatus(), 5000);
@@ -20,8 +28,8 @@ const Topbar = {
const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected'; const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected';
this._statusEl.className = `status-indicator ${wsStatus}`; this._statusEl.className = `status-indicator ${wsStatus}`;
this._statusEl.querySelector('.status-text').textContent = this._statusEl.querySelector('.status-text').textContent =
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接'; wsStatus === 'connected' ? `${Lang.t('connected')} (${status.ws_clients})` : Lang.t('disconnected');
} catch (e) {} } catch (e) { console.error(e); }
} }
}; };
+201 -47
View File
@@ -2,9 +2,12 @@ const PageDashboard = {
_el: null, _el: null,
_currentCategory: 'all', _currentCategory: 'all',
_selectedGameId: null, _selectedGameId: null,
_marketMode: false,
_marketToken: null,
init() { init() {
this._el = document.getElementById('page-dashboard'); this._el = document.getElementById('page-dashboard');
this._marketToken = localStorage.getItem('turbosu-market-token');
}, },
async render() { async render() {
@@ -24,7 +27,7 @@ const PageDashboard = {
const allItem = document.createElement('div'); const allItem = document.createElement('div');
allItem.className = 'category-item active'; allItem.className = 'category-item active';
allItem.textContent = '全部'; allItem.textContent = Lang.t('allCategory');
allItem.dataset.cat = 'all'; allItem.dataset.cat = 'all';
listEl.appendChild(allItem); listEl.appendChild(allItem);
@@ -43,30 +46,48 @@ const PageDashboard = {
async _loadThemes() { async _loadThemes() {
const gridEl = document.getElementById('dash-theme-grid'); const gridEl = document.getElementById('dash-theme-grid');
if (!gridEl) return; if (!gridEl) return;
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">' + Lang.t('loading') + '</div>';
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>'; if (this._marketMode) {
await this._loadMarket(gridEl);
return;
}
try { try {
let themes = await API.getDashboards(this._currentCategory); let themes = await API.getDashboards(this._currentCategory);
if (!themes || themes.length === 0) { if (!themes || themes.length === 0) { gridEl.innerHTML = this._emptyHtml(); return; }
gridEl.innerHTML = this._emptyHtml(); if (this._selectedGameId) { themes = themes.filter(t => this._isGameSupported(t)); }
return; const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || '';
} if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); }
const sortBy = document.getElementById('dash-sort')?.value || 'name';
if (this._selectedGameId) { themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
themes = themes.filter(t => this._isGameSupported(t)); if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml(Lang.t('noCompatibleDash')); return; }
}
if (themes.length === 0) {
gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘');
return;
}
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join(''); gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
} catch (e) { } catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('loadFailed') + '</div>'; }
console.error('Failed to load themes:', e); },
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
} async _loadMarket(gridEl) {
try {
const res = await fetch('/api/market/dashboards');
const data = await res.json();
const items = data.items || [];
if (!items.length) { gridEl.innerHTML = this._emptyHtml(Lang.t('marketEmpty')); 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)">${Lang.t('downloadCountShort').replace('%d', 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%">${Lang.t('install')}</button>
</div></div>`;
}).join('');
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('marketLoadFailed') + '</div>'; }
}, },
_isGameSupported(theme) { _isGameSupported(theme) {
@@ -80,8 +101,8 @@ const PageDashboard = {
return ` return `
<div class="empty-state" style="grid-column:1/-1;"> <div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="64" height="64"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
<h4>${msg || '暂无仪表盘主题'}</h4> <h4>${msg || Lang.t('noDashboards')}</h4>
<p>创建或导入你的仪表盘主题</p> <p>${Lang.t('createFirstDash')}</p>
</div>`; </div>`;
}, },
@@ -104,35 +125,142 @@ const PageDashboard = {
const card = e.target.closest('.theme-card'); const card = e.target.closest('.theme-card');
if (!card) return; if (!card) return;
const themeId = card.dataset.themeId; const themeId = card.dataset.themeId;
this._openDashboard(themeId); if (themeId) this._openDashboard(themeId);
const installBtn = e.target.closest('.btn-market-install');
if (installBtn) this._installFromMarket(installBtn.dataset.id, installBtn.dataset.file);
}); });
gridEl.addEventListener('contextmenu', (e) => { gridEl.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.theme-card'); const card = e.target.closest('.theme-card');
if (!card) return; if (!card || !card.dataset.themeId) return;
e.preventDefault(); e.preventDefault();
const themeId = card.dataset.themeId; this._exportDashboard(card.dataset.themeId);
this._exportDashboard(themeId);
}); });
} }
document.getElementById('dash-search')?.addEventListener('input', () => this._loadThemes());
document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes());
document.getElementById('btn-new-dashboard')?.addEventListener('click', () => this._createNew());
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => { document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
const input = document.createElement('input'); const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
input.type = 'file';
input.accept = '.tsd';
input.onchange = async (e) => { input.onchange = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0]; if (!file) return;
if (!file) return; const fd = new FormData(); fd.append('file', file);
const formData = new FormData(); const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
formData.append('file', file); const data = await res.json();
try { if (data.ok) { Toast.show(Lang.t('importSuccess'), 'success'); this._loadThemes(); }
const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData }); else Toast.show(Lang.t('importFailed'), 'error');
const data = await res.json(); }; input.click();
if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
else Toast.show('导入失败', 'error');
} catch (err) { 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(Lang.t('installSuccess'), 'success'); this._loadThemes(); }
else Toast.show(Lang.t('installFailed'), 'error');
} catch (e) { Toast.show(Lang.t('installFailed'), 'error'); }
},
_showMarketUpload() {
if (!this._marketToken) {
Toast.show(Lang.t('marketLoginPrompt'), '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 ? Lang.t('uploadFailedMsg') + data.error : Lang.t('uploadSuccess'), data.error ? 'error' : 'success');
} catch (e) { Toast.show(Lang.t('uploadFailed'), '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>${Lang.t('marketLogin')}</h3>
<div class="form-group"><label>${Lang.t('email')}</label><input id="ml-email" type="email"></div>
<div class="form-group"><label>${Lang.t('password')}</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">${Lang.t('logout')}</button>
<button class="btn btn-secondary btn-sm" id="ml-register">${Lang.t('register')}</button>
<button class="btn btn-primary btn-sm" id="ml-login">${Lang.t('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(Lang.t('loggedOut'), '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(Lang.t('loginSuccess'), 'success');
overlay.remove();
this._updateMarketUI();
} else { Toast.show(data.message || Lang.t('loginFailed'), '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 ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), 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 = Lang.t('loggedIn');
loginBtn.className = 'btn btn-sm btn-secondary';
uploadBtn.style.display = '';
} else {
loginBtn.textContent = Lang.t('login');
loginBtn.className = 'btn btn-sm btn-primary';
uploadBtn.style.display = 'none';
}
}, },
_openDashboard(themeId) { _openDashboard(themeId) {
@@ -140,7 +268,7 @@ const PageDashboard = {
const url = `${location.origin}/dashboard/${themeId}${gameParam}`; const url = `${location.origin}/dashboard/${themeId}${gameParam}`;
window.open(url, '_blank'); window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
Toast.show('链接已复制,可在局域网设备访问', 'success'); Toast.show(Lang.t('linkCopied'), 'success');
}).catch(() => { }).catch(() => {
Toast.show(`打开地址: ${url}`, 'info'); Toast.show(`打开地址: ${url}`, 'info');
}); });
@@ -151,7 +279,7 @@ const PageDashboard = {
a.href = `/api/dashboards/${themeId}/export`; a.href = `/api/dashboards/${themeId}/export`;
a.download = `${themeId}.tsd`; a.download = `${themeId}.tsd`;
a.click(); a.click();
Toast.show('正在下载 .tsd 文件...', 'info'); Toast.show(Lang.t('exportTsdHint'), 'info');
}, },
_themeCardHtml(theme) { _themeCardHtml(theme) {
@@ -159,11 +287,13 @@ const PageDashboard = {
const aspect = theme.aspect_ratio || 'auto'; const aspect = theme.aspect_ratio || 'auto';
const supported = theme.supported_games || 'all'; const supported = theme.supported_games || 'all';
const isAll = supported === 'all'; const isAll = supported === 'all';
const compatLabel = isAll ? '全' : '限'; const compatLabel = isAll ? Lang.t('fullCompatible') : Lang.t('limitedCompatible');
const compatClass = isAll ? 'badge-info' : 'badge-warning'; const compatClass = isAll ? 'badge-info' : 'badge-warning';
const preview = theme.preview_ext ? `/dashboard/${theme.id}/preview.${theme.preview_ext}` : '';
const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : '';
return ` return `
<div class="glass-card theme-card" data-theme-id="${theme.id}"> <div class="glass-card theme-card" data-theme-id="${theme.id}">
<div class="theme-card-preview">${icon}</div> <div class="theme-card-preview" style="${bgStyle}">${preview?'':icon}</div>
<div class="theme-card-info"> <div class="theme-card-info">
<div class="theme-card-name">${theme.name || 'Unnamed'}</div> <div class="theme-card-name">${theme.name || 'Unnamed'}</div>
<div class="theme-card-meta"> <div class="theme-card-meta">
@@ -178,16 +308,40 @@ const PageDashboard = {
</div>`; </div>`;
}, },
async _createNew() {
const name = prompt(Lang.t('dashboardNamePrompt'), '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(Lang.t('createSuccess'), 'success'); this._loadThemes(); }
else Toast.show(Lang.t('createFailed'), 'error');
} catch (e) { Toast.show(Lang.t('createFailed'), 'error'); }
},
_template() { _template() {
return ` return `
<div class="dashboard-layout"> <div class="dashboard-layout">
<div class="dashboard-sub-sidebar"> <div class="dashboard-sub-sidebar">
<div id="dash-category-list"></div> <div id="dash-category-list"></div>
<div style="padding:8px 12px;margin-top:auto;"> <div style="padding:8px 12px;margin-top:auto;display:flex;flex-direction:column;gap:4px">
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">导入 .tsd</button> <button id="btn-new-dashboard" class="btn btn-sm btn-primary" style="width:100%;">${Lang.t('newDashboard')}</button>
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">${Lang.t('importTsd')}</button>
</div> </div>
</div> </div>
<div class="dashboard-main"> <div class="dashboard-main">
<div id="dash-search-box" style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
<input id="dash-search" type="text" placeholder="${Lang.t('searchDashboards')}" style="flex:1;max-width:300px">
<select id="dash-sort" style="width:120px">
<option value="name">${Lang.t('sortByName')}</option>
<option value="category">${Lang.t('sortByCategory')}</option>
<option value="author">${Lang.t('sortByAuthor')}</option>
</select>
</div>
<div id="dash-theme-grid" class="theme-grid"></div> <div id="dash-theme-grid" class="theme-grid"></div>
</div> </div>
</div>`; </div>`;
+22 -10
View File
@@ -27,12 +27,23 @@ const PageDebug = {
document.getElementById('debug-pause-btn')?.addEventListener('click', () => { document.getElementById('debug-pause-btn')?.addEventListener('click', () => {
this._paused = !this._paused; this._paused = !this._paused;
const btn = document.getElementById('debug-pause-btn'); const btn = document.getElementById('debug-pause-btn');
btn.textContent = this._paused ? '▶ 继续' : '⏸ 暂停'; btn.textContent = this._paused ? Lang.t('resume') : Lang.t('pause');
}); });
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => { document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
this._autoScroll = e.target.checked; this._autoScroll = e.target.checked;
}); });
document.getElementById('debug-show-log')?.addEventListener('click', async () => {
try {
const res = await fetch('/api/logs?lines=100');
const data = await res.json();
const el = document.getElementById('debug-raw');
if (el) el.innerHTML = data.lines.map(l =>
`<span style="color:${l.includes('ERROR')?'red':l.includes('WARNING')?'#f90':l.includes('INFO')?'#888':'#555'}">${l}</span>`
).join('\n');
} catch (e) { console.error(e); }
});
}, },
_onTelemetry(data) { _onTelemetry(data) {
@@ -62,7 +73,7 @@ const PageDebug = {
<div class="debug-field-name">${k}</div> <div class="debug-field-name">${k}</div>
<div class="debug-field-value">${displayVal}</div> <div class="debug-field-value">${displayVal}</div>
</div>`; </div>`;
}).join('') || '<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>'; }).join('') || `<div style="color:var(--text-tertiary);padding:20px;">${Lang.t('waitingData')}</div>`;
}, },
_renderRaw() { _renderRaw() {
@@ -75,7 +86,7 @@ const PageDebug = {
return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`; return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`;
}); });
el.innerHTML = lines.join('\n') || '等待数据...'; el.innerHTML = lines.join('\n') || Lang.t('waitingData');
if (this._autoScroll) { if (this._autoScroll) {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
@@ -90,23 +101,24 @@ const PageDebug = {
return ` return `
<div class="debug-container"> <div class="debug-container">
<div class="debug-toolbar animated"> <div class="debug-toolbar animated">
<h2 style="font-size:20px;font-weight:700;">数据测试</h2> <h2 style="font-size:20px;font-weight:700;">${Lang.t('debugPage')}</h2>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button id="debug-pause-btn" class="btn btn-sm btn-secondary">⏸ 暂停</button> <button id="debug-pause-btn" class="btn btn-sm btn-secondary">${Lang.t('pause')}</button>
<button id="debug-clear-btn" class="btn btn-sm btn-secondary">清空</button> <button id="debug-clear-btn" class="btn btn-sm btn-secondary">${Lang.t('clear')}</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);"> <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动 <input type="checkbox" id="debug-autoscroll" checked> ${Lang.t('autoScroll')}
</label> </label>
<button id="debug-show-log" class="btn btn-sm btn-secondary">${Lang.t('viewLog')}</button>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
<div style="overflow-y:auto;"> <div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">解析后的数据字段</h4> <h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">${Lang.t('parsedFields')}</h4>
<div id="debug-fields"> <div id="debug-fields">
<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div> <div style="color:var(--text-tertiary);padding:20px;">${Lang.t('waitingData')}</div>
</div> </div>
</div> </div>
<div style="overflow-y:auto;"> <div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">原始 JSON 数据流</h4> <h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">${Lang.t('rawJsonStream')}</h4>
<div id="debug-raw" class="debug-data"></div> <div id="debug-raw" class="debug-data"></div>
</div> </div>
</div> </div>
+20 -20
View File
@@ -15,25 +15,25 @@ const PageHome = {
async _refreshCards() { async _refreshCards() {
try { try {
const status = await API.getStatus(); const status = await API.getStatus();
this._updateCard('card-server', status.server_running ? '运行中' : '已停止', status.server_running ? 'success' : 'danger'); this._updateCard('card-server', status.server_running ? Lang.t('running') : Lang.t('stopped'), status.server_running ? 'success' : 'danger');
this._updateCard('card-telemetry', status.telemetry_running ? '监听中' : '未启动', status.telemetry_running ? 'success' : 'warning'); this._updateCard('card-telemetry', status.telemetry_running ? Lang.t('listening') : Lang.t('notStarted'), status.telemetry_running ? 'success' : 'warning');
this._updateCard('card-game', status.selected_game_id || '未选择', 'info'); this._updateCard('card-game', status.selected_game_id || Lang.t('notSelected'), 'info');
this._updateCard('card-connections', `${status.ws_clients} 个客户端`, status.ws_clients > 0 ? 'success' : 'secondary'); this._updateCard('card-connections', Lang.t('connectionsCount').replace('%d', status.ws_clients), status.ws_clients > 0 ? 'success' : 'secondary');
this._updateCard('card-packets', `${status.packet_count}`, status.packet_count > 0 ? 'success' : 'secondary'); this._updateCard('card-packets', Lang.t('packetsCount').replace('%d', status.packet_count), status.packet_count > 0 ? 'success' : 'secondary');
const lastTime = status.last_packet_time; const lastTime = status.last_packet_time;
if (lastTime > 0) { if (lastTime > 0) {
const ago = Math.round((Date.now() / 1000) - lastTime); const ago = Math.round((Date.now() / 1000) - lastTime);
this._updateCard('card-last-packet', `${ago}秒前`, ago < 5 ? 'success' : 'warning'); this._updateCard('card-last-packet', Lang.t('secondsAgo').replace('%d', ago), ago < 5 ? 'success' : 'warning');
} else { } else {
this._updateCard('card-last-packet', '暂无数据', 'secondary'); this._updateCard('card-last-packet', Lang.t('noData'), 'secondary');
} }
const td = status.latest_data; const td = status.latest_data;
if (td) { if (td) {
this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary'); this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary');
this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary'); this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary');
this._updateCard('card-gear', `档位 ${td.gear || 'N'}`, 'primary'); this._updateCard('card-gear', Lang.t('gearLabel').replace('%s', td.gear || 'N'), 'primary');
} }
} catch (e) { } catch (e) {
console.error('Failed to refresh status:', e); console.error('Failed to refresh status:', e);
@@ -66,21 +66,21 @@ const PageHome = {
_template() { _template() {
return ` return `
<div class="animated"> <div class="animated">
<h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">系统概览</h2> <h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">${Lang.t('systemOverview')}</h2>
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p> <p style="color:var(--text-secondary);margin-bottom:24px;">${Lang.t('overviewDesc')}</p>
</div> </div>
<div class="status-grid animated" style="animation-delay:0.1s"> <div class="status-grid animated" style="animation-delay:0.1s">
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')} ${this._cardHtml('card-speed', Lang.t('liveSpeed'), '-- km/h', 'primary')}
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')} ${this._cardHtml('card-rpm', Lang.t('liveRpm'), '-- RPM', 'primary')}
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')} ${this._cardHtml('card-gear', Lang.t('liveGear'), '--', 'primary')}
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
</div> </div>
<div class="status-grid animated" style="animation-delay:0.2s"> <div class="status-grid animated" style="animation-delay:0.15s">
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')} ${this._cardHtml('card-server', Lang.t('serverStatus'), Lang.t('running'), 'success')}
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')} ${this._cardHtml('card-telemetry', Lang.t('telemetryStatus'), Lang.t('notStarted'), 'warning')}
${this._cardHtml('card-gear', '当前档位', 'N', 'primary')} ${this._cardHtml('card-connections', Lang.t('wsConnections'), Lang.t('connectionsCount').replace('%d', 0), 'secondary')}
${this._cardHtml('card-packets', Lang.t('packetCount'), Lang.t('packetsCount').replace('%d', 0), 'secondary')}
${this._cardHtml('card-last-packet', Lang.t('lastPacket'), Lang.t('noData'), 'secondary')}
${this._cardHtml('card-game', Lang.t('currentGame'), Lang.t('notSelected'), 'info')}
</div>`; </div>`;
}, },
+498
View File
@@ -0,0 +1,498 @@
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);">' + Lang.t('loading') + '</div>';
if (this._tab === 'mine') {
if (!this._marketToken) {
grid.innerHTML = this._emptyHtml(Lang.t('needLogin'));
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 ? Lang.t('noScenesMarket') : Lang.t('noDashboardsMarket')); 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);">' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\'btn-refresh-market\').click();return false" style="color:var(--accent)">' + Lang.t('retry') + '</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(Lang.t('noUploads'));
return;
}
let html = '';
if (data.dashboards?.length) {
html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">' + Lang.t('myDashboards') + '</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)">' + Lang.t('myScenes') + '</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);">' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\"btn-refresh-market\").click();return false" style="color:var(--accent)">' + Lang.t('retry') + '</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 ? Lang.t('installed') : (isScene ? Lang.t('installScene') : Lang.t('install'));
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%">` + Lang.t('uninstall') + '</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)">${Lang.t('downloadCountShort').replace('%d', 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">' + Lang.t('pending') + '</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">' + Lang.t('approved') + '</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||''} · ${Lang.t('downloadCount').replace('%d', 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'}">${Lang.t('updateItem')}</button>
<button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">${Lang.t('delete')}</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(Lang.t('confirmUninstall').replace('"$1"', 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(Lang.t('uninstallSuccess'), 'success');
this._loadItems();
} else { Toast.show(Lang.t('uninstallNotFound'), 'error'); }
} catch (e) { Toast.show(Lang.t('uninstallFailed'), 'error'); }
return;
}
if (btnUpdate) {
this._showUpdateModal(btnUpdate.dataset);
return;
}
if (btnDelete) {
if (!confirm(Lang.t('confirmDelete'))) 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(Lang.t('deleted'), 'info');
this._loadItems();
} catch (e) { Toast.show(Lang.t('deleteFailed'), 'error'); }
return;
}
const id = (btnInstall || btnScene)?.dataset.id;
const file = (btnInstall || btnScene)?.dataset.file;
if (!id || !file) { Toast.show(Lang.t('installDataIncomplete'), '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(Lang.t('installedHint'), 'success', 4000);
this._loadItems();
} else {
Toast.show(Lang.t('installFailedMsg') + (data.error||''), 'error');
}
} catch (e) { Toast.show(Lang.t('installNetworkError'), '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 = Lang.t('loggedIn');
loginBtn.className = 'btn btn-sm btn-secondary';
uploadBtn.style.display = '';
if (statusEl) statusEl.textContent = ' ' + Lang.t('loggedIn');
} else {
loginBtn.textContent = Lang.t('login');
loginBtn.className = 'btn btn-sm btn-primary';
uploadBtn.style.display = 'none';
if (statusEl) statusEl.textContent = ' ' + Lang.t('notLoggedIn');
}
},
_upload() {
if (!this._marketToken) { Toast.show(Lang.t('needLogin'), '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">' + Lang.t('publishTitle') + '</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">' + Lang.t('publishDesc') + '</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> ' + Lang.t('uploadPreview') + '</label></div><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">' + Lang.t('chooseFile') + '</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">' + Lang.t('orChooseLocal') + '</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">' + Lang.t('loading') + '</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 = Lang.t('dropPreview');
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(Lang.t('previewTooLarge'), 'error'); return; }
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
previewFile = f;
previewDrop.textContent = '\u2713 ' + 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(Lang.t('imageOnly'), 'error'); return; }
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
previewFile = f;
previewDrop.textContent = '\u2713 ' + 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 = Lang.t('dropPreview');
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 ? Lang.t('publishFailed') + ': '+data.error : Lang.t('publishSuccess'), 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> ' + Lang.t('localDashboards') + ' ('+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+'">' + Lang.t('publish') + '</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> ' + Lang.t('localScenes') + ' ('+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+'">' + Lang.t('publish') + '</button></div>').join('') + '</div>';
}
list.innerHTML = h || '<p style="color:var(--text-tertiary);font-size:12px;padding:10px">' + Lang.t('noPublishable') + '</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?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),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?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success');
if(!d.error){overlay.remove();this._loadItems();}
}));
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">' + Lang.t('loadFailed') + '</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">${Lang.t('login')}</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">${Lang.t('register')}</button>
</div>
<div class="form-group"><label>${Lang.t('email')}</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>${Lang.t('username')}</label><input id="ml-name" type="text" placeholder="Required"></div>
<div class="form-group"><label>${Lang.t('password')}</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)">${Lang.t('logout')}</button>
<span style="flex:1"></span>
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?Lang.t('login'):Lang.t('register')}</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(Lang.t('loggedOut'), '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 ? Lang.t('login') : Lang.t('register');
}
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(Lang.t('enterEmailPassword'), '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(Lang.t('loginSuccess'), 'success');
overlay.remove();
this._updateUI();
} else { Toast.show(data.message || Lang.t('loginFailed'), '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 ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), 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||Lang.t('marketNoItems')}</h4></div>`;
},
_showUpdateModal(ds) {
if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; }
const isScene = ds.coll === 'scenes';
const typeLabel = isScene ? Lang.t('scene') : Lang.t('dashboard');
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">' + Lang.t('updateItem') + ' ' + typeLabel + '</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 = Lang.t('dropPreview');
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(Lang.t('previewTooLarge'), 'error'); return; }
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
previewFile = f; previewDrop.textContent = '\u2713 '+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(Lang.t('imageOnly'), 'error'); return; }
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
previewFile = f; previewDrop.textContent = '\u2713 '+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 = Lang.t('dropPreview'); 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 = '\u2713 '+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">' + Lang.t('confirmUpdate') + '</button>');
overlay.querySelector('#pub-do-update').addEventListener('click', async () => {
if (!updateFile && !previewFile) { Toast.show(Lang.t('selectFileOrPreview'), '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 ? Lang.t('updateFailed') + data.error : Lang.t('updated'), 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">${Lang.t('marketPage')}</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${Lang.t('marketDesc')} <span id="m-status" style="color:var(--text-tertiary)"> ${Lang.t('notLoggedIn')}</span></p>
</div>
<div style="display:flex;gap:8px">
<button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">${Lang.t('publish')}</button>
<button id="btn-m-login" class="btn btn-sm btn-primary">${Lang.t('login')}</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">${Lang.t('dashboardsTab')}</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">${Lang.t('scenesTab')}</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">${Lang.t('myTab')}</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="${Lang.t('refresh')}">🔄</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">' + Lang.t('noRecordings') + '</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 + '">' + Lang.t('play') + '</button><a class="btn btn-sm btn-secondary" href="/api/recording/' + f.name + '/export" download>' + Lang.t('export') + '</a><button class="btn btn-sm btn-danger btn-del-rec" data-file="' + f.name + '">' + Lang.t('delete') + '</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(Lang.t('importSuccess'), '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(Lang.t('confirmDelete'))) {
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 ? Lang.t('recording') : Lang.t('ready');
},
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(Lang.t('playbackStartedHint'), 'info');
},
_template() {
return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:8px">' + Lang.t('recordPage') + '</h2><p style="color:var(--text-secondary);margin-bottom:20px">' + Lang.t('recordDesc') + '</p>' +
'<div class="glass-card" style="padding:20px;margin-bottom:20px"><h3 style="font-size:16px;margin-bottom:12px">' + Lang.t('recordControl') + '</h3><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><button id="btn-rec-start" class="btn btn-primary">' + Lang.t('startRecord') + '</button><button id="btn-rec-stop" class="btn btn-danger" style="display:none">' + Lang.t('stopRecord') + '</button><span id="rec-status" style="font-size:13px;color:var(--text-secondary)">' + Lang.t('ready') + '</span><span style="flex:1"></span><label class="switch-label"><input type="checkbox" id="rec-auto-mode"><span class="switch-track"></span> ' + Lang.t('autoDetect') + '</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">' + Lang.t('playbackActive') + '<strong id="playback-file">-</strong></span><span style="flex:1"></span><span style="font-size:12px;color:var(--text-tertiary)">' + Lang.t('playbackHint') + '</span><button id="btn-playback-stop" class="btn btn-sm btn-danger">' + Lang.t('stopPlayback') + '</button></div>' +
'<div class="glass-card" style="padding:20px"><h3 style="font-size:16px;margin-bottom:12px">' + Lang.t('recordFiles') + '</h3><div style="display:flex;gap:8px;margin-bottom:12px"><button id="btn-import-rec" class="btn btn-sm btn-secondary">' + Lang.t('importTsr') + '</button></div><div id="rec-file-list"></div></div>' +
'</div>';
}
};
window.PageRecord = PageRecord;
+211 -163
View File
@@ -3,7 +3,14 @@ const PageScene = {
_currentGameId: null, _currentGameId: null,
_currentGameName: '', _currentGameName: '',
_editorSceneId: null, _editorSceneId: null,
_editorData: null, _editorCanvasIdx: 0,
_placements: [],
_dashboards: [],
_dragItem: null,
_dragOffsetX: 0,
_dragOffsetY: 0,
_resizeItem: null,
_resizeDir: '',
init() { init() {
this._el = document.getElementById('page-scene'); this._el = document.getElementById('page-scene');
@@ -12,208 +19,249 @@ const PageScene = {
async render() { async render() {
const cfg = await API.getConfig(); const cfg = await API.getConfig();
this._currentGameId = cfg.selected_game_id; this._currentGameId = cfg.selected_game_id;
if (this._currentGameId) { if (this._currentGameId) {
const games = await API.getGames(); const games = await API.getGames();
const game = games.find(g => g.id === this._currentGameId); const game = games.find(g => g.id === this._currentGameId);
this._currentGameName = game ? game.name : this._currentGameId; this._currentGameName = game ? game.name : this._currentGameId;
this._dashboards = await API.getDashboards('all');
} }
this._el.innerHTML = this._template(); this._el.innerHTML = this._template();
await this._loadScenes(); this._showSceneList();
this._bindEvents(); this._bindEvents();
}, },
async _loadScenes() { async _showSceneList() {
document.getElementById('scene-list-container').style.display = 'block';
document.getElementById('scene-editor-container').style.display = 'none';
const grid = document.getElementById('scene-grid'); const grid = document.getElementById('scene-grid');
if (!grid) return; if (!grid) return;
try { try {
const scenes = await API.getScenes(this._currentGameId); const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) { if (!scenes || scenes.length === 0) {
grid.innerHTML = ` grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>${Lang.t('noScenes')}</h4><p>${Lang.t('createFirstScene')}</p></div>`;
<div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<h4>暂无场景</h4>
<p>点击"新建场景"创建你的第一个场景布局</p>
</div>`;
return; return;
} }
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join(''); grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) { } catch (e) { grid.innerHTML = '<div class="empty-state"><p>' + Lang.t('loadFailed') + '</p></div>'; }
console.error('Failed to load scenes:', e);
}
}, },
_bindEvents() { async _openEditor(sceneId) {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor()); this._editorSceneId = sceneId;
document.getElementById('btn-import-scene')?.addEventListener('click', () => { this._editorCanvasIdx = 0;
const input = document.createElement('input'); this._placements = [];
input.type = 'file';
input.accept = '.tss';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/scenes/import', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) { Toast.show('场景导入成功', 'success'); this._loadScenes(); }
else Toast.show('导入失败', 'error');
} catch (err) { Toast.show('导入失败', 'error'); }
};
input.click();
});
const grid = document.getElementById('scene-grid');
if (grid) {
grid.addEventListener('click', (e) => {
const card = e.target.closest('[data-scene-id]');
if (!card) return;
const action = card.dataset.action;
const sceneId = card.dataset.sceneId;
if (action === 'render') {
this._renderScene(sceneId);
} else if (action === 'edit') {
e.stopPropagation();
this._showEditor(sceneId);
} else if (action === 'delete') {
e.stopPropagation();
this._deleteScene(sceneId);
} else if (action === 'export') {
e.stopPropagation();
this._exportScene(sceneId);
}
});
}
},
async _showEditor(sceneId) {
let scene = null; let scene = null;
if (sceneId) { if (sceneId) {
scene = await API.getScene(sceneId); scene = await API.getScene(sceneId);
if (!scene) return; if (scene && scene.canvases && scene.canvases[0]) {
} this._placements = scene.canvases[0].placements || [];
this._editorSceneId = sceneId;
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = this._editorTemplate(scene);
document.body.appendChild(modal);
modal.querySelector('.modal-overlay, .modal-actions .btn-secondary')
?.addEventListener('click', (e) => {
if (e.target === modal || e.target.matches('.btn-secondary')) {
modal.remove();
}
});
modal.querySelector('.btn-primary')?.addEventListener('click', async () => {
const name = modal.querySelector('#edit-scene-name').value || 'New Scene';
const desc = modal.querySelector('#edit-scene-desc').value || '';
const gameId = this._currentGameId || '';
const data = { name, description: desc, game_id: gameId };
if (this._editorSceneId) {
await API.updateScene(this._editorSceneId, data);
} else {
await API.createScene(data);
} }
modal.remove(); }
Toast.show('场景保存成功', 'success'); const listC = document.getElementById('scene-list-container');
this.render(); 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">' + Lang.t('noCompatibleDashForScene') + '</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(Lang.t('sceneSaveSuccess'), 'success');
this._showSceneList();
} catch (e) { Toast.show(Lang.t('saveFailed') + 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(Lang.t('sceneImportSuccess'), 'success'); this._showSceneList(); }
else Toast.show(Lang.t('importFailed'), '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(Lang.t('confirmDelete'))) { 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}`; const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank'); window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => Toast.show(Lang.t('linkCopiedShort'), 'success')).catch(() => {});
Toast.show('场景链接已复制,可在局域网设备访问', 'success');
}).catch(() => {});
},
async _deleteScene(sceneId) {
if (!confirm('确定删除此场景?')) return;
await API.deleteScene(sceneId);
Toast.show('场景已删除', 'info');
this._loadScenes();
},
_exportScene(sceneId) {
const a = document.createElement('a');
a.href = `/api/scenes/${sceneId}/export`;
a.download = `${sceneId}.tss`;
a.click();
Toast.show('正在下载 .tss 文件...', 'info');
}, },
_sceneCardHtml(scene) { _sceneCardHtml(scene) {
const canvasCount = (scene.canvases || []).length; const canvasCount = (scene.canvases || []).length;
return ` const preview = scene.preview_ext ? `/scene/${scene.id}/preview.${scene.preview_ext}` : '';
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render"> return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
<div class="scene-card-name">${scene.name}</div> ${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div> <div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
<div class="scene-card-meta"> <div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || Lang.t('noGame')}</div>
<span>${canvasCount} 个画布</span> <div class="scene-card-meta"><span>${Lang.t('canvasesLabel').replace('%d', canvasCount)}</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span> <div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
</div> <div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
<div class="scene-canvas-list"> <button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">${Lang.t('render')}</button>
${(scene.canvases || []).map(c => `<span class="scene-canvas-badge">${c.label || c.width + 'x' + c.height}</span>`).join('')} <button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">${Lang.t('edit')}</button>
</div> <button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">${Lang.t('export')}</button>
<div style="margin-top:12px;display:flex;gap:6px;"> <button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">${Lang.t('delete')}</button>
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button> </div></div>`;
<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>`;
}, },
_template() { _template() {
return ` return `
<div class="scene-header animated"> <div class="scene-header animated" id="scene-list-container">
<div> <div>
<h2>场景管理</h2> <h2>${Lang.t('sceneManagement')}</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;"> <p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? Lang.t('currentGamePrefix') + this._currentGameName : Lang.t('noGameSelected')}</p>
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px">
<button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button> <button id="btn-import-scene" class="btn btn-secondary btn-sm">${Lang.t('importTss')}</button>
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}> <button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>${Lang.t('newScene')}</button>
+ 新建场景
</button>
</div> </div>
</div> </div>
<div id="scene-grid" class="scene-grid animated"></div>`; <div id="scene-grid" class="scene-grid animated"></div>
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:12px">
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">${Lang.t('backToList')}</button>
<input id="edit-scene-name" placeholder="${Lang.t('sceneName')}" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
</div>
<button id="btn-save-scene" class="btn btn-primary btn-sm">${Lang.t('saveScene')}</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">${Lang.t('dashListDrag')}</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>`;
} }
}; };
+164 -20
View File
@@ -11,6 +11,12 @@ const PageSettings = {
const forwards = await this._getForwards(); const forwards = await this._getForwards();
this._el.innerHTML = this._template(cfg, games, forwards); this._el.innerHTML = this._template(cfg, games, forwards);
this._bindEvents(); this._bindEvents();
this._toggleGamePorts(cfg.use_unified_port !== false);
},
_toggleGamePorts(hide) {
const el = document.getElementById('game-ports-section');
if (el) el.style.display = hide ? 'none' : 'block';
}, },
async _getForwards() { async _getForwards() {
@@ -25,12 +31,18 @@ const PageSettings = {
_bindEvents() { _bindEvents() {
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => { document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 }); await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
Toast.show('遥测端口已更新,重启监听后生效', 'info'); Toast.show(Lang.t('portUpdated'), '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(Lang.t('configSaved'), 'info');
}); });
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => { document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
await API.stopTelemetry(); await API.startTelemetry(); await API.stopTelemetry(); await API.startTelemetry();
Toast.show('遥测监听已重启', 'success'); Toast.show(Lang.t('telemetryRestarted'), 'success');
}); });
document.getElementById('btn-add-forward')?.addEventListener('click', () => this._addForward()); document.getElementById('btn-add-forward')?.addEventListener('click', () => this._addForward());
@@ -49,13 +61,13 @@ const PageSettings = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/games/install', { method: 'POST', body: fd }); const res = await fetch('/api/games/install', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
if (data.ok) { Toast.show('插件安装成功', 'success'); this.render(); } if (data.ok) { Toast.show(Lang.t('pluginInstalled'), 'success'); this.render(); }
else Toast.show('安装失败', 'error'); else Toast.show(Lang.t('importFailed'), 'error');
}; input.click(); }; input.click();
}); });
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => { document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
await API.reloadGamePlugins(); Toast.show('插件已重新加载', 'success'); this.render(); await API.reloadGamePlugins(); Toast.show(Lang.t('pluginReloaded'), 'success'); this.render();
}); });
document.getElementById('settings-game-list')?.addEventListener('click', async (e) => { document.getElementById('settings-game-list')?.addEventListener('click', async (e) => {
@@ -66,7 +78,7 @@ const PageSettings = {
const a = document.createElement('a'); a.href = '/api/games/' + pid + '/export'; a.download = pid + '.tsp'; a.click(); const a = document.createElement('a'); a.href = '/api/games/' + pid + '/export'; a.download = pid + '.tsp'; a.click();
} }
if (removeBtn) { if (removeBtn) {
if (confirm('移除?')) { await API.removeGamePlugin(removeBtn.dataset.pluginId); Toast.show('已移除', 'info'); this.render(); } if (confirm(Lang.t('confirmRemove'))) { await API.removeGamePlugin(removeBtn.dataset.pluginId); Toast.show(Lang.t('removeSuccess'), 'info'); this.render(); }
} }
}); });
@@ -77,9 +89,92 @@ const PageSettings = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd }); const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error'); Toast.show(data.ok ? Lang.t('importSuccess') : Lang.t('importFailed'), data.ok ? 'success' : 'error');
}; input.click(); }; input.click();
}); });
document.getElementById('btn-export-backup')?.addEventListener('click', () => {
const a = document.createElement('a');
a.href = '/api/backup/export';
a.download = 'turbosu_backup.tsb';
a.click();
});
document.getElementById('btn-import-backup')?.addEventListener('click', () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsb';
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/backup/import', { method: 'POST', body: fd });
const data = await res.json();
Toast.show(data.ok ? data.message || Lang.t('restoreSuccess') : Lang.t('restoreFailed') + data.message, data.ok ? 'success' : 'error');
}; input.click();
});
document.getElementById('btn-restart-server')?.addEventListener('click', async () => {
if (!confirm(Lang.t('confirmRestart'))) return;
try {
await fetch('/api/restart', { method: 'POST' });
Toast.show(Lang.t('restarting'), 'info');
setTimeout(() => location.reload(), 3000);
} catch (e) {
Toast.show(Lang.t('restartRequestSent'), '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 = Lang.t('recording');
});
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 = Lang.t('stopped');
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(Lang.t('testModeOn'), 'success');
} else {
await fetch('/api/test-mode/stop', { method: 'POST' });
Toast.show(Lang.t('testModeOff'), '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(Lang.t('portsSaved'), 'success');
});
}, },
_addForward() { _addForward() {
@@ -87,7 +182,7 @@ const PageSettings = {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'forward-row glass-card'; row.className = 'forward-row glass-card';
row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap'; row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap';
row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" checked> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button>'; row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label class="switch-label"><input type="checkbox" class="forward-toggle" checked><span class="switch-track"></span> ' + Lang.t('forwardEnabled') + '</label><button class="btn btn-sm btn-danger btn-del-forward">x</button>';
list.appendChild(row); list.appendChild(row);
}, },
@@ -101,33 +196,82 @@ const PageSettings = {
const enabled = r.querySelector('.forward-toggle')?.checked !== false; const enabled = r.querySelector('.forward-toggle')?.checked !== false;
if (host && port > 0) targets.push({ host, port, name, enabled }); if (host && port > 0) targets.push({ host, port, name, enabled });
}); });
this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success')); this._saveForwards(targets).then(() => Toast.show(Lang.t('forwardsSaved').replace('%d', 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">' + Lang.t('noRecordings') + '</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 + '">' + Lang.t('play') + '</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(Lang.t('playbackStarted'), 'success');
});
});
} catch (e) { console.error(e); }
},
_gamePortListHtml(games, cfg) {
const gamePorts = cfg.game_ports || {};
return games.map(g => {
const proto = g.protocol || 'udp';
const mode = g.mode || (proto === 'tcp' ? 'server' : '');
const protoLabel = proto.toUpperCase() + (mode ? '/' + mode : '');
return '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:120px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><span style="width:70px;font-size:11px;color:var(--text-tertiary)">' + protoLabel + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">' + Lang.t('defaultPortLabel') + (g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '</span></div>';
}).join('');
}, },
_forwardListHtml(forwards) { _forwardListHtml(forwards) {
if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">暂无转发目标,点击下方添加</p>'; if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">' + Lang.t('noForwardTargets') + '</p>';
return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-secondary);white-space:nowrap"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">×</button></div>').join(''); return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label class="switch-label"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '><span class="switch-track"></span> ' + Lang.t('forwardEnabled') + '</label><button class="btn btn-sm btn-danger btn-del-forward">x</button></div>').join('');
}, },
_gamePluginListHtml(games) { _gamePluginListHtml(games) {
if (!games || games.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;">暂无游戏插件</p>'; if (!games || games.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;">' + Lang.t('noPlugins') + '</p>';
return games.map(g => '<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between"><div><div style="font-weight:600">' + g.name + '</div><div style="font-size:12px;color:var(--text-tertiary)">' + (g.description||'') + ' | v' + g.version + ' | ' + (g.author||'') + '<span class="badge ' + (g.is_builtin?'badge-info':'badge-success') + '" style="margin-left:6px">' + (g.is_builtin?'内置':'社区') + '</span></div></div><div style="display:flex;gap:6px"><button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="' + g.id + '">导出 .tsp</button>' + (g.is_builtin?'':'<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="' + g.id + '">移除</button>') + '</div></div>').join(''); return games.map(g => '<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between"><div><div style="font-weight:600">' + g.name + '</div><div style="font-size:12px;color:var(--text-tertiary)">' + (g.description||'') + ' | v' + g.version + ' | ' + (g.author||'') + '<span class="badge ' + (g.is_builtin?'badge-info':'badge-success') + '" style="margin-left:6px">' + (g.is_builtin ? Lang.t('builtin') : Lang.t('community')) + '</span></div></div><div style="display:flex;gap:6px"><button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="' + g.id + '">' + Lang.t('export') + ' .tsp</button>' + (g.is_builtin?'':'<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="' + g.id + '">' + Lang.t('remove') + '</button>') + '</div></div>').join('');
}, },
_template(cfg, games, forwards) { _template(cfg, games, forwards) {
return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:24px">设置</h2>' + const sections = [
{ id: 'sec-forward', label: Lang.t('dataForwarding') },
{ id: 'sec-connection', label: Lang.t('connectionSettings') },
{ id: 'sec-dashboards', label: Lang.t('dashManagement') },
{ id: 'sec-plugins', label: Lang.t('pluginManagement') },
{ id: 'sec-backup', label: Lang.t('dataBackup') },
{ id: 'sec-dev', label: Lang.t('devTools') },
{ id: 'sec-system', label: Lang.t('systemSection') },
];
'<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">' + Lang.t('settings') + '</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>' + Lang.t('dataForwarding') + '</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">' + Lang.t('dataForwardingDesc') + '</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">' + Lang.t('addTarget') + '</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">' + Lang.t('saveEffective') + '</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>' + Lang.t('connectionSettings') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('telemetryPort') + '</div><div class="settings-desc">' + Lang.t('telemetryPortDesc') + '</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">' + Lang.t('unifiedPortToggle') + '</div><div class="settings-desc">' + Lang.t('unifiedPortDesc') + '</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">' + Lang.t('perGamePort') + '</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">' + Lang.t('savePorts') + '</button></div><div class="settings-row"><div><div class="settings-label">' + Lang.t('restartListener') + '</div><div class="settings-desc">' + Lang.t('restartListenerDesc') + '</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">' + Lang.t('restartBtn') + '</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>' + Lang.t('dashManagement') + '</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">' + Lang.t('importTsd') + '</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">' + Lang.t('rightClickExport') + '</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>' + Lang.t('pluginManagement') + '</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">' + Lang.t('importTsp') + '</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">' + Lang.t('reloadPlugins') + '</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div></div>' +
'</div>'; '<div class="settings-section" id="sec-backup"><h3>' + Lang.t('dataBackup') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('exportBackup') + '</div><div class="settings-desc">' + Lang.t('backupDesc') + '</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">' + Lang.t('downloadTsb') + '</button></div></div><div class="settings-row"><div><div class="settings-label">' + Lang.t('restoreBackup') + '</div><div class="settings-desc">' + Lang.t('restoreBackupDesc') + '</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">' + Lang.t('importTsb') + '</button></div></div></div>' +
'<div class="settings-section" id="sec-dev"><h3>' + Lang.t('devTools') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('testMode') + '</div><div class="settings-desc">' + Lang.t('testModeDesc') + '</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>' + Lang.t('systemSection') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('restartServer') + '</div><div class="settings-desc">' + Lang.t('restartServerDesc') + '</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">' + Lang.t('restartBtnSys') + '</button></div></div></div>' +
'<div class="settings-section"><h3>' + Lang.t('about') + '</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">' + Lang.t('aboutDesc') + '<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, dashboard: PageDashboard,
scene: PageScene, scene: PageScene,
debug: PageDebug, debug: PageDebug,
record: PageRecord,
market: PageMarket,
settings: PageSettings, settings: PageSettings,
}, },
_currentPage: 'home', _currentPage: 'home',
+364
View File
@@ -0,0 +1,364 @@
const Lang = {
_current: 'zh',
_strings: {
zh: {
// Nav
home: '首页', dashboard: '仪表盘', scene: '场景', market: '市场',
record: '录制回放', debug: '数据测试', settings: '设置',
selectGame: '选择游戏', collapseSidebar: '折叠侧边栏',
// Connection
connected: '已连接', disconnected: '未连接', connectionError: '连接错误',
// Status cards
serverStatus: '服务器状态', telemetryStatus: '遥测监听',
wsConnections: 'WebSocket 连接', packetCount: '数据包接收',
lastPacket: '最后数据包', currentGame: '当前游戏',
liveSpeed: '实时速度', liveRpm: '实时转速', liveGear: '当前档位',
systemOverview: '系统概览', overviewDesc: '实时监控 TurboSu 运行状态与游戏遥测连接',
running: '运行中', stopped: '已停止', notStarted: '未启动',
noData: '暂无数据', notSelected: '未选择',
listening: '监听中',
// Dashboard
searchDashboards: '搜索仪表盘...', sortByName: '名称', sortByCategory: '分类',
sortByAuthor: '作者', importTsd: '导入 .tsd', newDashboard: '+ 新建',
noDashboards: '暂无仪表盘主题', createFirstDash: '创建或导入你的第一个仪表盘主题',
noCompatibleDash: '当前游戏没有兼容的仪表盘',
allCategory: '全部', builtin: '内置', custom: '自定义',
fullCompatible: '全', limitedCompatible: '限',
// Scene
sceneManagement: '场景管理', noGameSelected: '请先在侧边栏选择一个游戏',
newScene: '+ 新建场景', importTss: '导入 .tss',
noScenes: '暂无场景', createFirstScene: '点击"新建场景"创建你的第一个场景布局',
render: '渲染', edit: '编辑', delete: '删除', export: '导出',
sceneName: '场景名称', sceneDescription: '场景描述(可选)',
sceneCanvasHint: '提示:保存后可在场景编辑器中添加仪表盘、调整布局和画布比例。',
save: '保存', cancel: '取消', editScene: '编辑场景',
canvasesCount: '个画布', backToList: '← 返回', saveScene: '保存场景',
dashListDrag: '仪表盘列表 (拖到画布)', noCompatibleDashForScene: '无兼容仪表盘',
// Market
marketPage: '仪表盘市场', marketDesc: '浏览并安装社区分享的仪表盘和场景',
notLoggedIn: '未登录', loggedIn: '已登录',
login: '登录', register: '注册', logout: '退出',
publish: '发布你的作品', install: '安装', installed: '已安装',
installScene: '安装场景', uninstall: '卸载',
pending: '待审核', approved: '已通过',
marketLogin: '市场账户',
email: '邮箱', password: '密码', username: '用户名',
loginSuccess: '登录成功', loginFailed: '登录失败',
registerSuccess: '注册成功,请查收验证邮件(点击关闭)', registerFailed: '注册失败',
publishTitle: '发布作品', publishDesc: '选择要发布的仪表盘或场景',
chooseFile: '📁 选择文件 (.tsd / .tss)',
orChooseLocal: '— 或从本地选择 —',
localDashboards: '本地仪表盘', localScenes: '本地场景',
noPublishable: '没有可发布的内容', publishFailed: '发布失败',
publishSuccess: '发布成功',
uploadPreview: '上传预览图', dropPreview: '点击或拖放图片到此处 (最大 6MB)',
previewTooLarge: '预览图不能超过 6MB', imageOnly: '仅支持图片格式',
updateItem: '更新', confirmDelete: '确定删除?',
myItems: '我的', myDashboards: '我的仪表盘', myScenes: '我的场景',
noUploads: '你还没有上传任何内容',
refresh: '🔄', retry: '重试', loadFailed: '加载失败',
dashboardsTab: '仪表盘', scenesTab: '场景', myTab: '我的',
installedHint: '安装成功!请在仪表盘页面查看',
installFailed: '安装失败',
needLogin: '请先登录',
confirmUninstall: '确定卸载 "$1"',
// Record
recordPage: '录制与回放', recordDesc: '录制原始 UDP 流到 .tsr 文件,回放调试仪表盘',
recordControl: '录制控制', startRecord: '开始录制', stopRecord: '停止录制',
ready: '就绪', recording: '录制中...', autoDetect: '自动检测',
playbackActive: '正在回放: ', playbackHint: '打开仪表盘页面即可看到数据',
stopPlayback: '停止', recordFiles: '录制文件', importTsr: '导入 .tsr',
noRecordings: '暂无录制文件', play: '播放',
// Debug
debugPage: '数据测试', debugToolbar: '',
parsedFields: '解析后的数据字段', rawJsonStream: '原始 JSON 数据流',
waitingData: '等待数据...', pause: '⏸ 暂停', resume: '▶ 继续',
clear: '清空', autoScroll: '自动滚动', viewLog: '📄 日志',
// Settings
connectionSettings: '连接设置',
telemetryPort: '遥测监听端口', telemetryPortDesc: '全局统一端口',
tcpPort: 'TCP 默认端口', tcpPortDesc: 'TCP 协议默认监听端口',
unifiedPort: '统一端口', unifiedPortDesc: '关闭后切游戏自动切换到各游戏的默认端口',
unifiedPortToggle: '使用游戏独立端口',
perGamePort: '各游戏独立端口', savePorts: '保存端口',
restartListener: '重启遥测监听', restartListenerDesc: '修改端口或切换游戏后',
restartBtn: '重启监听',
dataForwarding: '数据转发', dataForwardingDesc: '将收到的游戏原始 UDP 数据包完整转发到下游物理外设',
addTarget: '+ 添加目标', saveEffective: '保存并生效',
noForwardTargets: '暂无转发目标,点击下方添加',
forwardEnabled: '启用',
dashManagement: '仪表盘管理', rightClickExport: '右键卡片导出',
pluginManagement: '游戏插件管理', importTsp: '导入 .tsp',
reloadPlugins: '重新加载', noPlugins: '暂无游戏插件',
dataBackup: '数据备份', exportBackup: '导出全量备份',
backupDesc: '打包仪表盘+场景+配置为 .tsb', downloadTsb: '下载 .tsb',
restoreBackup: '恢复备份', restoreBackupDesc: '从 .tsb 文件恢复',
importTsb: '导入 .tsb',
devTools: '开发工具', testMode: 'UI 测试模式',
testModeDesc: '本地随机模拟遥测数据',
testModeOn: '测试模式已开启', testModeOff: '测试模式已关闭',
systemSection: '系统', restartServer: '重启 TurboSu',
restartServerDesc: '重新加载所有代码和配置', restartBtnSys: '重启服务',
confirmRestart: '确定重启 TurboSu 服务?',
restarting: '服务正在重启,3秒后自动刷新...',
about: '关于', aboutDesc: '赛车遥测仪表盘 · 极速中枢 · Yei.J. (AskaEth)',
// General
loading: '加载中...', confirm: '确定', success: '成功', error: '错误',
clickClose: '点击关闭', saved: '已保存', updated: '已更新',
deleted: '已删除', portUpdated: '端口已更新,重启后生效',
configSaved: '已保存', telemetryRestarted: '遥测监听已重启',
pluginInstalled: '插件安装成功', pluginRemoved: '插件已移除',
pluginReloaded: '插件已重新加载',
// Counters & formats
connectionsCount: '%d 个客户端', packetsCount: '%d 包',
secondsAgo: '%d秒前', gearLabel: '档位 %s',
downloadCount: '%d 次下载', downloadCountShort: '%d 次',
canvasesLabel: '%d 个画布', forwardsSaved: '已保存 %d 个转发目标',
defaultPortLabel: '默认: ',
// More UI
noGamePlugins: '暂无可用的游戏插件',
noGame: '未关联游戏', currentGamePrefix: '当前游戏: ',
community: '社区',
noScenesMarket: '暂无场景', noDashboardsMarket: '暂无仪表盘',
// Prompts / modals
dashboardNamePrompt: '仪表盘名称:',
marketLoginPrompt: '请先登录市场账户',
enterEmailPassword: '请输入邮箱和密码',
selectFileOrPreview: '请选择文件或预览图',
confirmRemove: '移除?',
// Toast: import / create / upload
importSuccess: '导入成功', importFailed: '导入失败',
createSuccess: '创建成功', createFailed: '创建失败',
uploadSuccess: '上传成功', uploadFailed: '上传失败',
uploadFailedMsg: '上传失败: ',
installSuccess: '安装成功', installFailedMsg: '安装失败: ',
remove: '移除',
uninstallSuccess: '已卸载', uninstallFailed: '卸载失败',
uninstallNotFound: '未找到本地安装',
deleteFailed: '删除失败', loggedOut: '已退出',
sceneSaveSuccess: '场景保存成功', saveFailed: '保存失败: ',
sceneImportSuccess: '场景导入成功',
playbackStarted: '回放已开始', playbackStartedHint: '回放已开始,打开仪表盘查看数据',
restoreSuccess: '恢复成功', restoreFailed: '恢复失败: ',
removeSuccess: '已移除',
exportTsdHint: '正在下载 .tsd 文件...',
linkCopied: '链接已复制,可在局域网设备访问',
linkCopiedShort: '链接已复制',
portsSaved: '端口已保存',
marketEmpty: '市场暂无仪表盘', marketLoadFailed: '市场加载失败',
installDataIncomplete: '安装失败:数据不完整',
installNetworkError: '安装失败:网络错误',
restartRequestSent: '重启请求已发送,请手动刷新',
marketNoItems: '暂无',
confirmUpdate: '确认更新',
},
en: {
home: 'Home', dashboard: 'Dashboard', scene: 'Scene', market: 'Market',
record: 'Record', debug: 'Debug', settings: 'Settings',
selectGame: 'Select Game', collapseSidebar: 'Collapse',
connected: 'Connected', disconnected: 'Disconnected', connectionError: 'Error',
serverStatus: 'Server', telemetryStatus: 'Telemetry',
wsConnections: 'Connections', packetCount: 'Packets',
lastPacket: 'Last Packet', currentGame: 'Game',
liveSpeed: 'Speed', liveRpm: 'RPM', liveGear: 'Gear',
systemOverview: 'System Overview', overviewDesc: 'Real-time monitoring',
running: 'Running', stopped: 'Stopped', notStarted: 'Off',
noData: 'No Data', notSelected: 'None',
listening: 'Listening',
searchDashboards: 'Search...', sortByName: 'Name', sortByCategory: 'Category',
sortByAuthor: 'Author', importTsd: 'Import .tsd', newDashboard: '+ New',
noDashboards: 'No dashboards', createFirstDash: 'Create or import your first dashboard',
noCompatibleDash: 'No compatible dashboards for current game',
allCategory: 'All', builtin: 'Built-in', custom: 'Custom',
fullCompatible: 'All', limitedCompatible: 'Lim',
sceneManagement: 'Scene Manager', noGameSelected: 'Select a game first',
newScene: '+ New Scene', importTss: 'Import .tss',
noScenes: 'No scenes', createFirstScene: 'Create your first scene',
render: 'Render', edit: 'Edit', delete: 'Delete', export: 'Export',
sceneName: 'Name', sceneDescription: 'Description (optional)',
sceneCanvasHint: 'Tip: Save first, then edit layout in scene editor.',
save: 'Save', cancel: 'Cancel', editScene: 'Edit Scene',
canvasesCount: 'canvases', backToList: '← Back', saveScene: 'Save Scene',
dashListDrag: 'Dashboards (drag to canvas)', noCompatibleDashForScene: 'No compatible dashboards',
marketPage: 'Marketplace', marketDesc: 'Browse and install community dashboards & scenes',
notLoggedIn: 'Not logged in', loggedIn: 'Logged in',
login: 'Login', register: 'Register', logout: 'Logout',
publish: 'Publish', install: 'Install', installed: 'Installed',
installScene: 'Install Scene', uninstall: 'Uninstall',
pending: 'Pending', approved: 'Approved',
marketLogin: 'Account',
email: 'Email', password: 'Password', username: 'Username',
loginSuccess: 'Login successful', loginFailed: 'Login failed',
registerSuccess: 'Registered! Check email to verify (click to close)', registerFailed: 'Registration failed',
publishTitle: 'Publish', publishDesc: 'Select a dashboard or scene',
chooseFile: '📁 Choose file (.tsd / .tss)',
orChooseLocal: '— or select from local —',
localDashboards: 'Local Dashboards', localScenes: 'Local Scenes',
noPublishable: 'Nothing to publish', publishFailed: 'Publish failed',
publishSuccess: 'Published successfully',
uploadPreview: 'Upload Preview', dropPreview: 'Click or drop image (max 6MB)',
previewTooLarge: 'Preview too large (6MB max)', imageOnly: 'Images only',
updateItem: 'Update', confirmDelete: 'Confirm delete?',
myItems: 'Mine', myDashboards: 'My Dashboards', myScenes: 'My Scenes',
noUploads: 'Nothing uploaded yet',
refresh: '🔄', retry: 'Retry', loadFailed: 'Load failed',
dashboardsTab: 'Dashboards', scenesTab: 'Scenes', myTab: 'Mine',
installedHint: 'Installed! Check dashboard page',
installFailed: 'Install failed',
needLogin: 'Please login first',
confirmUninstall: 'Uninstall "$1"?',
recordPage: 'Record & Playback', recordDesc: 'Record raw UDP to .tsr, replay for debugging',
recordControl: 'Record Control', startRecord: 'Start', stopRecord: 'Stop',
ready: 'Ready', recording: 'Recording...', autoDetect: 'Auto Detect',
playbackActive: 'Playing: ', playbackHint: 'Open a dashboard to see data',
stopPlayback: 'Stop', recordFiles: 'Recordings', importTsr: 'Import .tsr',
noRecordings: 'No recordings', play: 'Play',
debugPage: 'Debug', debugToolbar: '',
parsedFields: 'Parsed Fields', rawJsonStream: 'Raw JSON Stream',
waitingData: 'Waiting...', pause: '⏸ Pause', resume: '▶ Resume',
clear: 'Clear', autoScroll: 'Auto Scroll', viewLog: '📄 Log',
connectionSettings: 'Connection',
telemetryPort: 'Telemetry Port', telemetryPortDesc: 'Global port',
tcpPort: 'TCP Port', tcpPortDesc: 'Default TCP port',
unifiedPort: 'Unified Port', unifiedPortDesc: 'Turn off for per-game ports',
unifiedPortToggle: 'Use per-game ports',
perGamePort: 'Per-Game Ports', savePorts: 'Save Ports',
restartListener: 'Restart Listener', restartListenerDesc: 'After port/game change',
restartBtn: 'Restart',
dataForwarding: 'Data Forwarding', dataForwardingDesc: 'Forward raw UDP to downstream devices',
addTarget: '+ Add', saveEffective: 'Save',
noForwardTargets: 'No targets, add below',
forwardEnabled: 'Enabled',
dashManagement: 'Dashboard Management', rightClickExport: 'Right-click to export',
pluginManagement: 'Plugin Management', importTsp: 'Import .tsp',
reloadPlugins: 'Reload', noPlugins: 'No plugins',
dataBackup: 'Backup', exportBackup: 'Export Backup',
backupDesc: 'Pack dashboards+scenes+config as .tsb', downloadTsb: 'Download .tsb',
restoreBackup: 'Restore Backup', restoreBackupDesc: 'Restore from .tsb',
importTsb: 'Import .tsb',
devTools: 'Dev Tools', testMode: 'UI Test Mode',
testModeDesc: 'Simulate telemetry locally',
testModeOn: 'Test mode on', testModeOff: 'Test mode off',
systemSection: 'System', restartServer: 'Restart TurboSu',
restartServerDesc: 'Reload all code and config', restartBtnSys: 'Restart',
confirmRestart: 'Restart TurboSu?',
restarting: 'Restarting, will refresh in 3s...',
about: 'About', aboutDesc: 'Racing Telemetry Dashboard · Yei.J. (AskaEth)',
loading: 'Loading...', confirm: 'Confirm', success: 'Success', error: 'Error',
clickClose: 'Click to close', saved: 'Saved', updated: 'Updated',
deleted: 'Deleted', portUpdated: 'Port updated, restart to apply',
configSaved: 'Saved', telemetryRestarted: 'Listener restarted',
pluginInstalled: 'Plugin installed', pluginRemoved: 'Plugin removed',
pluginReloaded: 'Plugins reloaded',
connectionsCount: '%d clients', packetsCount: '%d packets',
secondsAgo: '%ds ago', gearLabel: 'Gear %s',
downloadCount: '%d downloads', downloadCountShort: '%d',
canvasesLabel: '%d canvases', forwardsSaved: '%d targets saved',
defaultPortLabel: 'Default: ',
noGamePlugins: 'No game plugins available',
noGame: 'No game', currentGamePrefix: 'Current game: ',
community: 'Community',
noScenesMarket: 'No scenes', noDashboardsMarket: 'No dashboards',
dashboardNamePrompt: 'Dashboard name:',
marketLoginPrompt: 'Please login first',
enterEmailPassword: 'Enter email and password',
selectFileOrPreview: 'Select file or preview',
confirmRemove: 'Remove?',
importSuccess: 'Imported', importFailed: 'Import failed',
createSuccess: 'Created', createFailed: 'Create failed',
uploadSuccess: 'Uploaded', uploadFailed: 'Upload failed',
uploadFailedMsg: 'Upload failed: ',
installSuccess: 'Installed', installFailedMsg: 'Install failed: ',
remove: 'Remove',
uninstallSuccess: 'Uninstalled', uninstallFailed: 'Uninstall failed',
uninstallNotFound: 'Not found locally',
deleteFailed: 'Delete failed', loggedOut: 'Logged out',
sceneSaveSuccess: 'Scene saved', saveFailed: 'Save failed: ',
sceneImportSuccess: 'Scene imported',
playbackStarted: 'Playback started', playbackStartedHint: 'Playback started, open dashboard to view',
restoreSuccess: 'Restored', restoreFailed: 'Restore failed: ',
removeSuccess: 'Removed',
exportTsdHint: 'Downloading .tsd...',
linkCopied: 'Link copied, accessible on LAN',
linkCopiedShort: 'Link copied',
portsSaved: 'Ports saved',
marketEmpty: 'No dashboards on market', marketLoadFailed: 'Market load failed',
installDataIncomplete: 'Incomplete data',
installNetworkError: 'Network error',
restartRequestSent: 'Restart sent, refresh manually',
marketNoItems: 'None',
confirmUpdate: 'Confirm Update',
}
},
init() {
this._current = localStorage.getItem('turbosu-lang') || 'zh';
const btn = document.getElementById('lang-toggle');
if (btn) btn.textContent = this._current === 'zh' ? 'EN' : '中';
this._scan();
},
t(key) {
return (this._strings[this._current] || this._strings.zh)[key] || key;
},
_scan() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const text = this.t(key);
if (el.tagName === 'INPUT' && (el.type === 'text' || el.type === 'search' || el.type === 'email' || el.type === 'password')) {
el.placeholder = text;
} else {
el.textContent = text;
}
});
},
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 = { const Theme = {
_current: 'dark', _current: 'dark',
_toggling: false,
init() { init() {
this._current = localStorage.getItem('turbosu-theme') || 'dark'; this._current = localStorage.getItem('turbosu-theme') || 'dark';
@@ -7,8 +8,11 @@ const Theme = {
}, },
toggle() { toggle() {
if (this._toggling) return;
this._toggling = true;
this._current = this._current === 'dark' ? 'light' : 'dark'; this._current = this._current === 'dark' ? 'light' : 'dark';
this.apply(); this.apply();
setTimeout(() => { this._toggling = false; }, 200);
}, },
apply() { apply() {
+17 -6
View File
@@ -13,14 +13,25 @@ const Toast = {
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
toast.style.cursor = 'pointer';
toast.addEventListener('click', () => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(20px)';
toast.style.transition = 'all 0.2s ease';
setTimeout(() => toast.remove(), 200);
});
this._container.appendChild(toast); this._container.appendChild(toast);
setTimeout(() => { if (duration > 0) {
toast.style.opacity = '0'; setTimeout(() => {
toast.style.transform = 'translateX(100%)'; if (toast.parentNode) {
toast.style.transition = 'all 0.3s ease'; toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300); toast.style.transform = 'translateY(20px)';
}, duration); toast.style.transition = 'all 0.3s ease';
setTimeout(() => toast.remove(), 300);
}
}, duration);
}
} }
}; };
+17 -3
View File
@@ -5,15 +5,26 @@ const WS = {
_reconnectDelay: 2000, _reconnectDelay: 2000,
_handlers: {}, _handlers: {},
_connected: false, _connected: false,
_lastPong: 0,
init() { init() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
this._url = `${protocol}//${location.host}/ws`; this._url = `${protocol}//${location.host}/ws`;
this._connect(); this._connect();
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !this._connected) {
this._reconnectDelay = 500;
this._connect();
}
if (!document.hidden && this._connected) {
this.send({type: 'ping'});
}
});
}, },
_connect() { _connect() {
if (this._ws && this._ws.readyState === WebSocket.OPEN) return; if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return;
try { try {
this._ws = new WebSocket(this._url); this._ws = new WebSocket(this._url);
@@ -24,6 +35,7 @@ const WS = {
this._ws.onopen = () => { this._ws.onopen = () => {
this._connected = true; this._connected = true;
this._lastPong = Date.now();
this._reconnectDelay = 2000; this._reconnectDelay = 2000;
this._emit('connection', true); this._emit('connection', true);
this._emit('status', 'connected'); this._emit('status', 'connected');
@@ -32,11 +44,12 @@ const WS = {
this._ws.onmessage = (event) => { this._ws.onmessage = (event) => {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'pong') { this._lastPong = Date.now(); return; }
this._emit(msg.type || 'message', msg.data || msg); this._emit(msg.type || 'message', msg.data || msg);
if (msg.type === 'telemetry') { if (msg.type === 'telemetry') {
this._emit('telemetry', msg.data); this._emit('telemetry', msg.data);
} }
} catch (e) {} } catch (e) { console.error(e); }
}; };
this._ws.onclose = () => { this._ws.onclose = () => {
@@ -47,7 +60,8 @@ const WS = {
}; };
this._ws.onerror = () => { this._ws.onerror = () => {
this._emit('status', 'error'); this._emit('status', 'disconnected');
this._connected = false;
}; };
}, },
+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))); });
+40 -17
View File
@@ -3,20 +3,19 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TurboSu - 赛车遥测仪表盘</title> <link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
<title>TurboSu</title>
<link rel="stylesheet" href="/static/css/miuix.css"> <link rel="stylesheet" href="/static/css/miuix.css">
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css">
</head> </head>
<body class="theme-dark"> <body class="theme-dark">
<div id="sidebar-backdrop" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:199" onclick="toggleMobileMenu()"></div>
<div id="app" class="app-layout"> <div id="app" class="app-layout">
<aside id="sidebar" class="sidebar expanded"> <aside id="sidebar" class="sidebar expanded">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-logo"> <div class="sidebar-logo">
<svg viewBox="0 0 48 48" width="40" height="40"> <img src="/static/img/logo.svg" alt="TurboSu" width="40" height="40">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">S</text>
</svg>
</div> </div>
<div class="sidebar-title">TurboSu</div> <div class="sidebar-title">TurboSu</div>
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏"> <button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
@@ -26,17 +25,17 @@
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<a href="#/home" class="nav-item" data-route="home"> <a href="#/home" class="nav-item" data-route="home">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>
<span class="nav-label">首页</span> <span class="nav-label" data-i18n="home">首页</span>
</a> </a>
<a href="#/dashboard" class="nav-item" data-route="dashboard"> <a href="#/dashboard" class="nav-item" data-route="dashboard">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
<span class="nav-label">仪表盘</span> <span class="nav-label" data-i18n="dashboard">仪表盘</span>
</a> </a>
<div id="game-selector-container" class="nav-section"> <div id="game-selector-container" class="nav-section">
<button id="game-selector-toggle" class="nav-item collapsible-toggle collapsed"> <button id="game-selector-toggle" class="nav-item collapsible-toggle collapsed">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H3V8h18v8zM6 15h2v-2h2v-2H8V9H6v2H4v2h2z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H3V8h18v8zM6 15h2v-2h2v-2H8V9H6v2H4v2h2z" fill="currentColor"/></svg>
<span class="nav-label" id="game-selector-label">选择游戏</span> <span class="nav-label" id="game-selector-label" data-i18n="selectGame">选择游戏</span>
<svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" fill="currentColor"/></svg> <svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" fill="currentColor"/></svg>
</button> </button>
<div id="game-list" class="nav-submenu collapsed"> <div id="game-list" class="nav-submenu collapsed">
@@ -46,7 +45,7 @@
<div id="scene-nav-container" class="nav-section"> <div id="scene-nav-container" class="nav-section">
<a href="#/scene" class="nav-item" data-route="scene"> <a href="#/scene" class="nav-item" data-route="scene">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<span class="nav-label">场景</span> <span class="nav-label" data-i18n="scene">场景</span>
</a> </a>
</div> </div>
</nav> </nav>
@@ -54,11 +53,19 @@
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<a href="#/debug" class="nav-item" data-route="debug"> <a href="#/debug" class="nav-item" data-route="debug">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/></svg>
<span class="nav-label">数据测试</span> <span class="nav-label" data-i18n="debug">数据测试</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" data-i18n="record">录制回放</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" data-i18n="market">市场</span>
</a> </a>
<a href="#/settings" class="nav-item" data-route="settings"> <a href="#/settings" class="nav-item" data-route="settings">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg> <svg viewBox="0 0 24 24" width="20" height="20"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg>
<span class="nav-label">设置</span> <span class="nav-label" data-i18n="settings">设置</span>
</a> </a>
</div> </div>
</aside> </aside>
@@ -66,11 +73,11 @@
<main class="main-content"> <main class="main-content">
<header id="topbar" class="topbar"> <header id="topbar" class="topbar">
<div class="topbar-left"> <div class="topbar-left">
<div id="topbar-logo-title" class="topbar-logo-title hidden"> <button id="mobile-menu-btn" class="icon-btn" style="display:none" onclick="toggleMobileMenu()">
<svg viewBox="0 0 48 48" width="28" height="28"> <svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" fill="currentColor"/></svg>
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/> </button>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/> <div id="topbar-logo-title" class="topbar-logo-title hidden" style="cursor:pointer" onclick="toggleMobileMenu()">
</svg> <img src="/static/img/logo.svg" alt="TurboSu" width="28" height="28">
<span class="topbar-title-text">TurboSu</span> <span class="topbar-title-text">TurboSu</span>
</div> </div>
</div> </div>
@@ -83,6 +90,7 @@
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM4 10.5H1v2h3zm9-9.95h-2V3.5h2zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5v2h3v-2zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41z" fill="currentColor"/></svg> <svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM4 10.5H1v2h3zm9-9.95h-2V3.5h2zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5v2h3v-2zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41z" fill="currentColor"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20"><path d="M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49z" fill="currentColor"/></svg> <svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20"><path d="M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49z" fill="currentColor"/></svg>
</button> </button>
<button id="lang-toggle" class="icon-btn" title="Language" style="font-size:13px;font-weight:600"></button>
</div> </div>
</header> </header>
@@ -95,6 +103,10 @@
</div> </div>
<div id="page-debug" class="page"> <div id="page-debug" class="page">
</div> </div>
<div id="page-record" class="page">
</div>
<div id="page-market" class="page">
</div>
<div id="page-settings" class="page"> <div id="page-settings" class="page">
</div> </div>
</div> </div>
@@ -103,6 +115,7 @@
<script src="/static/js/utils/theme.js"></script> <script src="/static/js/utils/theme.js"></script>
<script src="/static/js/utils/toast.js"></script> <script src="/static/js/utils/toast.js"></script>
<script src="/static/js/utils/lang.js"></script>
<script src="/static/js/api.js"></script> <script src="/static/js/api.js"></script>
<script src="/static/js/ws.js"></script> <script src="/static/js/ws.js"></script>
<script src="/static/js/components/sidebar.js"></script> <script src="/static/js/components/sidebar.js"></script>
@@ -111,8 +124,18 @@
<script src="/static/js/pages/dashboard.js"></script> <script src="/static/js/pages/dashboard.js"></script>
<script src="/static/js/pages/scene.js"></script> <script src="/static/js/pages/scene.js"></script>
<script src="/static/js/pages/debug.js"></script> <script src="/static/js/pages/debug.js"></script>
<script src="/static/js/pages/record.js"></script>
<script src="/static/js/pages/market.js"></script>
<script src="/static/js/pages/settings.js"></script> <script src="/static/js/pages/settings.js"></script>
<script src="/static/js/router.js"></script> <script src="/static/js/router.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script>
function toggleMobileMenu() {
var sb = document.getElementById('sidebar');
var bd = document.getElementById('sidebar-backdrop');
var open = sb.classList.toggle('mobile-open');
bd.style.display = open ? 'block' : 'none';
}
</script>
</body> </body>
</html> </html>
-2
View File
@@ -124,8 +124,6 @@
} }
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
const cw = parseInt(sceneCanvas.style.width) / (parseInt(sceneCanvas.style.width) / 1920) || 1920;
const ch = parseInt(sceneCanvas.style.height) / (parseInt(sceneCanvas.style.height) / 1080) || 1080;
const vw = window.innerWidth; const vw = window.innerWidth;
const vh = window.innerHeight; const vh = window.innerHeight;
const scale = Math.min(vw / cw, vh / ch); const scale = Math.min(vw / cw, vh / ch);
+39 -12
View File
@@ -1,36 +1,63 @@
import logging import logging
import os import os
import sys import sys
import uuid
from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
LOG_DIR = Path(__file__).resolve().parent.parent / "logs" LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True) LOG_DIR.mkdir(exist_ok=True)
LOG_FILE = LOG_DIR / "turbosu.log"
LOG_FORMAT = logging.Formatter( LOG_FORMAT = logging.Formatter(
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", "[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )
MAX_LOG_FILES = 20
_root_handler: RotatingFileHandler | None = None
_console_handler: logging.StreamHandler | None = None
def _init_handlers():
global _root_handler, _console_handler
if _root_handler:
return
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
uid = uuid.uuid4().hex[:6]
log_file = LOG_DIR / f"turbosu_{ts}_{uid}.log"
_root_handler = RotatingFileHandler(
log_file, maxBytes=10 * 1024 * 1024, backupCount=20, encoding="utf-8"
)
_root_handler.setLevel(logging.DEBUG)
_root_handler.setFormatter(LOG_FORMAT)
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setLevel(logging.INFO)
_console_handler.setFormatter(LOG_FORMAT)
logs = sorted(LOG_DIR.glob("turbosu_*.log*"), key=lambda p: p.stat().st_mtime)
while len(logs) > MAX_LOG_FILES:
oldest = logs.pop(0)
try:
oldest.unlink()
except OSError:
pass
def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger: def setup_logger(name: str, level: int = logging.DEBUG) -> logging.Logger:
_init_handlers()
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(level) logger.setLevel(level)
logger.propagate = False logger.propagate = False
if not logger.handlers: if not logger.handlers:
fh = RotatingFileHandler( logger.addHandler(_root_handler)
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" logger.addHandler(_console_handler)
)
fh.setLevel(logging.DEBUG)
fh.setFormatter(LOG_FORMAT)
logger.addHandler(fh)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.INFO)
ch.setFormatter(LOG_FORMAT)
logger.addHandler(ch)
return logger return logger