Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b976d83cf2 | |||
| de58a1c42f | |||
| 860bc015e8 | |||
| 5cea0f9851 | |||
| 5bc2c18f06 | |||
| 4becc26e45 | |||
| e4fe499bef | |||
| 759d35bc93 | |||
| f715cb85e6 | |||
| 1ebea5eb1a | |||
| 36755cc78a | |||
| 5630d0e332 | |||
| 29d51ad59e | |||
| 5bb4a82ae0 | |||
| 2ca8dc53f4 | |||
| 9cb8c12c89 | |||
| fb36eba07d |
@@ -86,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 遥测变量
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ async def dashboard_preview(theme_id: str, ext: str):
|
|||||||
raise HTTPException(404)
|
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}")
|
@app.get("/scene/{scene_id}/preview.{ext}")
|
||||||
async def scene_preview(scene_id: str, ext: str):
|
async def scene_preview(scene_id: str, ext: str):
|
||||||
from models.scene import SCENES_DIR
|
from models.scene import SCENES_DIR
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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,
|
"use_unified_port": True,
|
||||||
"game_ports": {},
|
"game_ports": {},
|
||||||
"ui_test_mode": False,
|
"ui_test_mode": False,
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
|
||||||
|
# 📡 ACC Broadcasting API 完整规范
|
||||||
|
|
||||||
|
## 一、概述
|
||||||
|
|
||||||
|
ACC 有两种遥测数据通道:
|
||||||
|
|
||||||
|
| 通道 | 协议 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Broadcasting API** | UDP(双向) | 广播/观战、实时计时、多车数据 |
|
||||||
|
| **Shared Memory** | 共享内存(Windows) | 仪表盘、运动平台、FFB(仅玩家车辆) |
|
||||||
|
|
||||||
|
SDK 路径:`steamapps/common/Assetto Corsa Competizione/sdk/broadcasting/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、启用 Broadcasting
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
编辑 `文档/Assetto Corsa Competizione/Config/broadcasting.json`(**必须 UTF-16LE BOM 编码**):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"updListenerPort": 9000,
|
||||||
|
"connectionPassword": "asd",
|
||||||
|
"commandPassword": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `updListenerPort` | ACC 监听的 UDP 端口(默认 9000) |
|
||||||
|
| `connectionPassword` | 客户端连接密码 |
|
||||||
|
| `commandPassword` | 控制命令密码(留空则所有已连接客户端都可发送命令) |
|
||||||
|
|
||||||
|
### 测试客户端
|
||||||
|
|
||||||
|
运行 `sdk/broadcasting/Testclient/ksBroadcastingTestClient.exe`,输入端口和密码后点击 Connect 即可验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、协议规范
|
||||||
|
|
||||||
|
### 基本规则
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **协议版本**:`BROADCASTING_PROTOCOL_VERSION = 2`
|
||||||
|
- **字符串编码**:UTF-8,前缀 `uint16` 长度字段
|
||||||
|
- **通信流程**:客户端 → ACC 发起注册 → ACC 回传数据流
|
||||||
|
|
||||||
|
### 数据序列化规则
|
||||||
|
|
||||||
|
| 类型 | 字节数 |
|
||||||
|
|---|---|
|
||||||
|
| `byte` / `int8` | 1 |
|
||||||
|
| `uint16` / `int16` | 2 |
|
||||||
|
| `int32` / `uint32` | 4 |
|
||||||
|
| `float32` | 4 |
|
||||||
|
| `string` | 2(长度)+ N(UTF-8 内容) |
|
||||||
|
|
||||||
|
### Lap 结构体(通用圈速格式)
|
||||||
|
|
||||||
|
```
|
||||||
|
int32 LaptimeMS // 圈速(ms),Int32.MaxValue = 无圈速
|
||||||
|
uint16 CarIndex // 车辆索引
|
||||||
|
uint16 DriverIndex // 车手索引
|
||||||
|
uint8 SplitCount // 分段数(通常 3)
|
||||||
|
int32[] Splits // 各分段成绩(ms),Int32.MaxValue = 无效
|
||||||
|
uint8 IsInvalid // 0=有效, 1=无效
|
||||||
|
uint8 IsValidForBest // 0=不可作最佳圈, 1=可作最佳圈
|
||||||
|
uint8 IsOutLap // 0=非出场圈, 1=出场圈
|
||||||
|
uint8 IsInLap // 0=非回场圈, 1=回场圈
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、出站消息(客户端 → ACC)
|
||||||
|
|
||||||
|
所有消息首字节为消息类型。
|
||||||
|
|
||||||
|
| 类型 ID | 消息名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` | **REGISTER_COMMAND_APPLICATION** | 注册连接(必须最先发送) |
|
||||||
|
| `9` | UNREGISTER_COMMAND_APPLICATION | 取消注册 |
|
||||||
|
| `10` | **REQUEST_ENTRY_LIST** | 请求参赛者列表 |
|
||||||
|
| `11` | **REQUEST_TRACK_DATA** | 请求赛道数据 |
|
||||||
|
| `49` | CHANGE_HUD_PAGE | 切换 HUD 页面 |
|
||||||
|
| `50` | **CHANGE_FOCUS** | 切换焦点车辆/摄像机 |
|
||||||
|
| `51` | **INSTANT_REPLAY_REQUEST** | 请求即时回放 |
|
||||||
|
| `52` | PLAY_MANUAL_REPLAY_HIGHLIGHT | (预留) |
|
||||||
|
| `60` | SAVE_MANUAL_REPLAY_HIGHLIGHT | (预留) |
|
||||||
|
|
||||||
|
### 1. REGISTER_COMMAND_APPLICATION(ID=1)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 1 // 消息类型
|
||||||
|
byte BROADCASTING_PROTOCOL_VERSION // = 2
|
||||||
|
string DisplayName // 显示名称
|
||||||
|
string ConnectionPassword // 连接密码(匹配 broadcasting.json)
|
||||||
|
int32 RealtimeUpdateInterval // 实时更新间隔(ms),建议 250
|
||||||
|
string CommandPassword // 命令密码
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. CHANGE_FOCUS(ID=50)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 50 // 消息类型
|
||||||
|
int32 ConnectionId // 连接 ID
|
||||||
|
uint8 HasCarChange // 0=不变, 1=切换车辆
|
||||||
|
uint16 CarIndex // 目标车辆索引(仅 HasCarChange=1)
|
||||||
|
uint8 HasCameraChange // 0=不变, 1=切换摄像机
|
||||||
|
string CameraSet // 摄像机集合名
|
||||||
|
string Camera // 摄像机名
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. INSTANT_REPLAY_REQUEST(ID=51)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 51 // 消息类型
|
||||||
|
int32 ConnectionId
|
||||||
|
float32 StartSessionTime // 回放起点(会话时间,ms)
|
||||||
|
float32 DurationMS // 回放时长(ms)
|
||||||
|
int32 InitialFocusedCar // 初始焦点车辆(-1 = 保持当前)
|
||||||
|
string InitialCameraSet
|
||||||
|
string InitialCamera
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. REQUEST_ENTRY_LIST(ID=10)/ REQUEST_TRACK_DATA(ID=11)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 10 或 11
|
||||||
|
int32 ConnectionId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、入站消息(ACC → 客户端)
|
||||||
|
|
||||||
|
| 类型 ID | 消息名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `1` | **REGISTRATION_RESULT** | 注册结果 |
|
||||||
|
| `2` | **REALTIME_UPDATE** | 会话实时信息(全局) |
|
||||||
|
| `3` | **REALTIME_CAR_UPDATE** | 车辆实时信息(每车) |
|
||||||
|
| `4` | **ENTRY_LIST** | 参赛者索引列表 |
|
||||||
|
| `5` | **TRACK_DATA** | 赛道和摄像机数据 |
|
||||||
|
| `6` | **ENTRY_LIST_CAR** | 单辆车详细信息 |
|
||||||
|
| `7` | **BROADCASTING_EVENT** | 广播事件 |
|
||||||
|
|
||||||
|
### 1. REGISTRATION_RESULT(ID=1)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 1
|
||||||
|
int32 ConnectionId // 分配的连接 ID(后续请求必须携带)
|
||||||
|
uint8 ConnectionSuccess // 0=失败, 1=成功
|
||||||
|
uint8 IsReadOnly // 0=可读写(即拥有 commandPassword)
|
||||||
|
string ErrorMessage // 错误信息
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后应立即发送 `REQUEST_ENTRY_LIST` 和 `REQUEST_TRACK_DATA`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. REALTIME_UPDATE(ID=2)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 2
|
||||||
|
uint16 EventIndex // 赛事索引
|
||||||
|
uint16 SessionIndex // 会话索引
|
||||||
|
uint8 SessionType // 见下表
|
||||||
|
uint8 Phase // 会话阶段(见下表)
|
||||||
|
float32 SessionTime // 会话已过时间 (ms)
|
||||||
|
float32 SessionEndTime // 会话剩余时间 (ms)
|
||||||
|
int32 FocusedCarIndex // 当前焦点车辆
|
||||||
|
string ActiveCameraSet // 当前摄像机集合
|
||||||
|
string ActiveCamera // 当前摄像机
|
||||||
|
string CurrentHUDPage // 当前 HUD 页面
|
||||||
|
uint8 IsReplayPlaying // 是否回放中
|
||||||
|
// 以下仅 IsReplayPlaying=1 时存在:
|
||||||
|
float32 ReplaySessionTime // 回放中的会话时间
|
||||||
|
float32 ReplayRemainingTime // 回放剩余时间
|
||||||
|
int32 ReplayFocusedCar
|
||||||
|
// 始终存在:
|
||||||
|
uint16 TimeOfDay // 时间 hh*100+mm
|
||||||
|
uint8 AmbientTemp // 环境温度 (°C)
|
||||||
|
uint8 TrackTemp // 赛道温度 (°C)
|
||||||
|
uint8 Clouds // 云量(÷10,即 0.0~1.0)
|
||||||
|
uint8 RainLevel // 降雨量(÷10)
|
||||||
|
uint8 Wetness // 路面湿度(÷10)
|
||||||
|
Lap BestSessionLap // 当前会话最佳圈速
|
||||||
|
```
|
||||||
|
|
||||||
|
#### RaceSessionType 枚举
|
||||||
|
|
||||||
|
| 值 | 类型 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Practice(练习) |
|
||||||
|
| 4 | Qualifying(排位赛) |
|
||||||
|
| 9 | Superpole(超级杆位) |
|
||||||
|
| 10 | Race(正赛) |
|
||||||
|
| 11 | Hotlap(单圈冲刺) |
|
||||||
|
| 12 | Hotstint(阶段冲刺) |
|
||||||
|
| 13 | HotlapSuperpole |
|
||||||
|
| 14 | Replay(回放) |
|
||||||
|
|
||||||
|
#### SessionPhase 枚举
|
||||||
|
|
||||||
|
| 值 | 阶段 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | NONE |
|
||||||
|
| 1 | Starting |
|
||||||
|
| 2 | PreFormation |
|
||||||
|
| 3 | FormationLap(编队圈) |
|
||||||
|
| 4 | PreSession |
|
||||||
|
| 5 | **Session**(绿灯起跑后) |
|
||||||
|
| 6 | SessionOver |
|
||||||
|
| 7 | PostSession |
|
||||||
|
| 8 | ResultUI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. REALTIME_CAR_UPDATE(ID=3)
|
||||||
|
|
||||||
|
**最重要的数据包**—每辆车独立发送。
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 3
|
||||||
|
uint16 CarIndex // 车辆索引(对应 EntryList)
|
||||||
|
uint16 DriverIndex // 当前车手索引
|
||||||
|
uint8 DriverCount // 该车车手总数
|
||||||
|
int8 Gear // -1=R, 0=N, 1~6...
|
||||||
|
float32 WorldPosX // 世界坐标 X(始终为 0)
|
||||||
|
float32 WorldPosY // 世界坐标 Y(始终为 0)
|
||||||
|
float32 Yaw // 偏航角(始终为 0)
|
||||||
|
uint8 CarLocation // 位置枚举(见下)
|
||||||
|
uint16 Kmh // 速度 (km/h)
|
||||||
|
uint16 Position // 排名(1-based)
|
||||||
|
uint16 CupPosition // 组别排名(1-based)
|
||||||
|
uint16 TrackPosition // 赛道位置序号(始终为 0)
|
||||||
|
float32 SplinePosition // 赛道位置 0.0~1.0
|
||||||
|
uint16 Laps // 已完成圈数
|
||||||
|
int32 Delta // 与最佳圈速的实时差距 (ms)
|
||||||
|
Lap BestSessionLap // 该车最佳圈速
|
||||||
|
Lap LastLap // 上一圈
|
||||||
|
Lap CurrentLap // 当前圈(时间持续更新,分段不填充)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CarLocation 枚举
|
||||||
|
|
||||||
|
| 值 | 位置 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | NONE |
|
||||||
|
| 1 | **Track**(赛道上) |
|
||||||
|
| 2 | Pitlane(维修区通道) |
|
||||||
|
| 3 | PitEntry(进站入口) |
|
||||||
|
| 4 | PitExit(出站出口) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. ENTRY_LIST(ID=4)+ ENTRY_LIST_CAR(ID=6)
|
||||||
|
|
||||||
|
**两步接收**:先收索引列表,再逐车收详情。
|
||||||
|
|
||||||
|
#### ENTRY_LIST 结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 4
|
||||||
|
int32 ConnectionId
|
||||||
|
uint16 CarEntryCount
|
||||||
|
uint16[] CarIndexes // 车辆索引数组
|
||||||
|
uint16 DriverEntryCount
|
||||||
|
uint16[] DriverIndexes // 保留字段
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ENTRY_LIST_CAR 结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 6
|
||||||
|
uint16 CarIndex // 对应 ENTRY_LIST 中的索引
|
||||||
|
uint8 CarModelType // 车型(见下)
|
||||||
|
string TeamName // 车队名
|
||||||
|
int32 RaceNumber // 车号
|
||||||
|
string TeamCarName // 车型名称
|
||||||
|
string DisplayName // 显示名
|
||||||
|
uint8 CupCategory // 组别:0=Overall/Pro, 1=ProAm, 2=Am, 3=Silver, 4=National
|
||||||
|
uint8 CurrentDriverId // 当前车手 ID
|
||||||
|
uint16 Nationality // 国籍(见附录)
|
||||||
|
uint8 DriversOnCarCount // 该车手数量
|
||||||
|
// 每个车手:
|
||||||
|
uint16 DriverIndex
|
||||||
|
uint8 HasDriverInfo // 0/1
|
||||||
|
string FirstName
|
||||||
|
string LastName
|
||||||
|
string Nickname
|
||||||
|
string ShortName // 缩写(如 "VER")
|
||||||
|
uint8 Category // 3=Platinum, 2=Gold, 1=Silver, 0=Bronze
|
||||||
|
uint16 Nationality
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CarModelType(部分)
|
||||||
|
|
||||||
|
| 值 | 车型 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Mercedes-AMG GT3 |
|
||||||
|
| 2 | Ferrari 488 GT3 |
|
||||||
|
| 15 | Lexus RC F GT3 |
|
||||||
|
| 16 | Lamborghini Huracán GT3 |
|
||||||
|
| 19 | Audi R8 LMS GT3 |
|
||||||
|
| 20 | Aston Martin Vantage GT3 |
|
||||||
|
| 23 | Porsche 911 GT3 R |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. TRACK_DATA(ID=5)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 5
|
||||||
|
int32 ConnectionId
|
||||||
|
string TrackName // 赛道名
|
||||||
|
int32 TrackId // 赛道 ID(见下)
|
||||||
|
int32 TrackMeters // 赛道长度 (m)
|
||||||
|
uint8 CameraSetCount
|
||||||
|
// 每个摄像机集合:
|
||||||
|
string CameraSetName
|
||||||
|
uint8 CameraCount
|
||||||
|
string[] CameraNames // 逐个摄像机名
|
||||||
|
uint8 HUDPageCount
|
||||||
|
string[] HUDPageNames
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TrackId(部分)
|
||||||
|
|
||||||
|
| ID | 赛道 |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Brands Hatch |
|
||||||
|
| 2 | Spa-Francorchamps |
|
||||||
|
| 3 | Monza |
|
||||||
|
| 4 | Misano |
|
||||||
|
| 5 | Paul Ricard |
|
||||||
|
| 6 | Silverstone |
|
||||||
|
| 7 | Hungaroring |
|
||||||
|
| 8 | Nürburgring |
|
||||||
|
| 9 | Barcelona |
|
||||||
|
| 10 | Zolder |
|
||||||
|
| 11 | Zandvoort |
|
||||||
|
| 13 | Mount Panorama (Bathurst) |
|
||||||
|
| 14 | Laguna Seca |
|
||||||
|
| 15 | Suzuka |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. BROADCASTING_EVENT(ID=7)
|
||||||
|
|
||||||
|
```
|
||||||
|
byte 7
|
||||||
|
uint8 Type // 事件类型
|
||||||
|
string Msg // 事件消息(常为圈速字符串)
|
||||||
|
int32 TimeMs // 自连接建立以来的时间 (ms)
|
||||||
|
int32 CarId // 相关车辆 ID
|
||||||
|
```
|
||||||
|
|
||||||
|
| 事件 Type | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 5 | LapCompleted(完成一圈) |
|
||||||
|
| 6 | BestSessionLap(全场最佳圈) |
|
||||||
|
| 7 | BestPersonalLap(个人最佳圈) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、Shared Memory API(补充)
|
||||||
|
|
||||||
|
用于本地仪表盘/运动平台,仅含玩家车辆数据。
|
||||||
|
|
||||||
|
### 三个内存页
|
||||||
|
|
||||||
|
| 页 | 大小 | 更新频率 | 内容 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **SPageFilePhysics** | ~800 B | 物理步进 | 速度、油门、刹车、G力、胎温等 |
|
||||||
|
| **SPageFileGraphic** | ~852 B | 图形帧率 | 圈速、排名、旗语、天气、油耗等 |
|
||||||
|
| **SPageFileStatic** | ~688 B | 固定 | 车辆名、赛道、最大RPM等 |
|
||||||
|
|
||||||
|
### Physics 关键字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `gas` | float | 油门 0.0~1.0 |
|
||||||
|
| `brake` | float | 刹车 0.0~1.0 |
|
||||||
|
| `gear` | int | 档位 |
|
||||||
|
| `rpm` | int | 引擎转速 |
|
||||||
|
| `speedKmh` | float | 速度 |
|
||||||
|
| `steerAngle` | float | 转向角 |
|
||||||
|
| `wheelPressure[4]` | float | 胎压 |
|
||||||
|
| `tyreCoreTemp[4]` | float | 轮胎内核温度 |
|
||||||
|
| `brakeTemp[4]` | float | 刹车温度 |
|
||||||
|
| `carDamage` | struct | 车辆损伤 |
|
||||||
|
| `fuel` | float | 油量 (L) |
|
||||||
|
| `abs` | float | ABS 作用量 |
|
||||||
|
| `tc` | float | TC 作用量 |
|
||||||
|
| `localVelocity` | Vector3f | 局部速度 |
|
||||||
|
| `localAngularVel` | Vector3f | 局部角速度 |
|
||||||
|
| `gForce` | Vector3f | G力 |
|
||||||
|
|
||||||
|
### Graphics 关键字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `status` | enum | AC_OFF=0, AC_LIVE=1 等 |
|
||||||
|
| `sessionType` | enum | 同 Broadcasting |
|
||||||
|
| `currentTime` | int | 当前圈速 (ms) |
|
||||||
|
| `lastTime` | int | 上一圈 (ms) |
|
||||||
|
| `bestTime` | int | 最佳圈 (ms) |
|
||||||
|
| `completedLaps` | int | 已完成圈数 |
|
||||||
|
| `position` | int | 排名 |
|
||||||
|
| `normalizedCarPosition` | float | 赛道位置 0.0~1.0 |
|
||||||
|
| `isInPit` | bool | 是否在维修区 |
|
||||||
|
| `flag` | enum | 旗语 |
|
||||||
|
| `fuelPerLap` | float | 每圈油耗 |
|
||||||
|
| `gapAhead` | int | 与前车差距 (ms) |
|
||||||
|
| `gapBehind` | int | 与后车差距 (ms) |
|
||||||
|
| `trackStatus` | string | Green/Fast/Optimum/Greasy/Damp/Wet/Flooded |
|
||||||
|
| `rainIntensity` | enum | 降雨强度 |
|
||||||
|
| `globalYellow` | bool | 黄旗 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、关键注意事项
|
||||||
|
|
||||||
|
1. **WorldPosX/Y 和 Yaw**:Broadcasting API 中始终为 0(出于反作弊考虑),需要全车坐标请用 Shared Memory
|
||||||
|
2. **EntryList**:在首次连接时发送一次,新会话开始时不自动重发
|
||||||
|
3. **TrackData**:同样只在连接时发送一次,新会话不重发
|
||||||
|
4. **SplinePosition**:0.0~1.0 表示车辆在赛道上的归一化位置(维修区也有效)
|
||||||
|
5. **Lap.Splits**:Int32.MaxValue 表示无效分段,超出分段数返回 0
|
||||||
|
6. **CarUpdate 中 Gear**:传输值 - 2 = 实际档位(传输 1 = -1/R,传输 2 = 0/N,传输 3 = 1 档...)
|
||||||
|
7. **broadcasting.json 编码**:必须是 UTF-16LE with BOM,否则 ACC 无法读取
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
|
||||||
|
## 🏎️ F1 24 UDP Telemetry 规范总览
|
||||||
|
|
||||||
|
### 📌 官方文档
|
||||||
|
- **官方规范 Word 文档**:[EA Forums — Data Output from F1 24 v27.2x.docx](https://forums.ea.com/t5/s/tghpe58374/attachments/tghpe58374/f1-24-general-discussion-en/4624/1/Data%20Output%20from%20F1%2024%20v27.2x.docx)
|
||||||
|
- **GitHub 开源解析库参考**:https://github.com/MacManley/f1-24-udp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 游戏设置
|
||||||
|
|
||||||
|
在游戏中启用 UDP 遥测:**Game Options → Settings → Telemetry Settings**
|
||||||
|
|
||||||
|
| 设置项 | 推荐值 |
|
||||||
|
|---|---|
|
||||||
|
| `UDP Telemetry` | On |
|
||||||
|
| `UDP Broadcast Mode` | Off(多设备时可开启) |
|
||||||
|
| `UDP IP Address` | 接收端 IP(本机用 `127.0.0.1`) |
|
||||||
|
| `Port` | `20777`(默认) |
|
||||||
|
| `UDP Send Rate` | `20Hz`(默认,最高 60Hz) |
|
||||||
|
| `UDP Format` | `2024` |
|
||||||
|
|
||||||
|
> ⚠️ 注意:游戏仅在玩家进入 **计时圈(timed lap)** 后才开始发送有效遥测数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 数据编码
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **对齐**:packed(无填充)
|
||||||
|
- **最大车辆数**:22
|
||||||
|
|
||||||
|
### 数据类型
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `uint8` / `int8` | 无符号/有符号 8-bit |
|
||||||
|
| `uint16` / `int16` | 无符号/有符号 16-bit |
|
||||||
|
| `uint32` | 无符号 32-bit |
|
||||||
|
| `uint64` | 无符号 64-bit |
|
||||||
|
| `float` | 32-bit 浮点 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 包头结构(PacketHeader)
|
||||||
|
|
||||||
|
**大小:29 bytes**
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PacketHeader {
|
||||||
|
uint16 m_packetFormat; // 2024
|
||||||
|
uint8 m_gameYear; // 年份后两位,如 24
|
||||||
|
uint8 m_gameMajorVersion; // 主版本号 "X.00"
|
||||||
|
uint8 m_gameMinorVersion; // 次版本号 "1.XX"
|
||||||
|
uint8 m_packetVersion; // 包类型版本号,从 1 开始
|
||||||
|
uint8 m_packetId; // 包类型 ID(见下表)
|
||||||
|
uint64 m_sessionUID; // 会话唯一 ID
|
||||||
|
float m_sessionTime; // 会话时间戳
|
||||||
|
uint32 m_frameIdentifier; // 帧标识符
|
||||||
|
uint32 m_overallFrameIdentifier; // 全局帧标识符(Flashback 不回退)
|
||||||
|
uint8 m_playerCarIndex; // 玩家车辆索引
|
||||||
|
uint8 m_secondaryPlayerCarIndex; // 分屏第二玩家索引(255 = 无)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆔 包类型 ID 汇总
|
||||||
|
|
||||||
|
| ID | 包名称 | 说明 | 频率 | 大小 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0 | **Motion** | 所有车辆物理运动数据 | 菜单设定频率 | 1349 B |
|
||||||
|
| 1 | **Session** | 会话信息(赛道、剩余时间) | 2/秒 | 753 B |
|
||||||
|
| 2 | **Lap Data** | 所有车辆圈速数据 | 菜单设定频率 | 1285 B |
|
||||||
|
| 3 | **Event** | 会话事件 | 事件触发时 | 45 B |
|
||||||
|
| 4 | **Participants** | 参与者列表 | 每 5 秒 | 1350 B |
|
||||||
|
| 5 | **Car Setups** | 车辆调校参数 | 2/秒 | 1133 B |
|
||||||
|
| 6 | **Car Telemetry** | 遥测数据(速度/油门/DRS等) | 菜单设定频率 | 1352 B |
|
||||||
|
| 7 | **Car Status** | 车辆状态(燃油/ERS等) | 菜单设定频率 | 1239 B |
|
||||||
|
| 8 | **Final Classification** | 最终排名 | 结果界面每 5 秒 | 1020 B |
|
||||||
|
| 9 | **Lobby Info** | 多人游戏大厅信息 | 大厅中 2/秒 | 1306 B |
|
||||||
|
| 10 | **Car Damage** | 车辆损伤数据 | 10/秒 | 953 B |
|
||||||
|
| 11 | **Session History** | 圈速和轮胎历史 | 20/秒(轮询车辆) | 1460 B |
|
||||||
|
| 12 | **Tyre Sets** | 轮胎组详细数据 | 2/秒 | 231 B |
|
||||||
|
| 13 | **Motion Ex** | 玩家车辆扩展运动数据 | 菜单设定频率 | 237 B |
|
||||||
|
| 14 | **Time Trial** | 计时赛专用数据 | 1/秒(仅TT) | 101 B |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 各包数据结构详解
|
||||||
|
|
||||||
|
### 1. Motion Packet(ID=0)
|
||||||
|
包含所有 22 辆车的物理数据。
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct CarMotionData {
|
||||||
|
float m_worldPositionX, m_worldPositionY, m_worldPositionZ; // 世界位置 (m)
|
||||||
|
float m_worldVelocityX, m_worldVelocityY, m_worldVelocityZ; // 世界速度 (m/s)
|
||||||
|
int16 m_worldForwardDirX, m_worldForwardDirY, m_worldForwardDirZ; // 前向向量(÷32767 归一化)
|
||||||
|
int16 m_worldRightDirX, m_worldRightDirY, m_worldRightDirZ; // 右向向量
|
||||||
|
float m_gForceLateral, m_gForceLongitudinal, m_gForceVertical; // G 力分量
|
||||||
|
float m_yaw, m_pitch, m_roll; // 角度 (rad)
|
||||||
|
};
|
||||||
|
// PacketMotionData: PacketHeader + CarMotionData[22]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Session Packet(ID=1)
|
||||||
|
包含当前会话完整信息,如天气、温度、赛道、规则、辅助设置等。
|
||||||
|
|
||||||
|
关键字段:
|
||||||
|
- `m_weather`: 0=晴, 1=少云, 2=阴, 3=小雨, 4=大雨, 5=暴雨
|
||||||
|
- `m_trackId`: 赛道 ID(-1=未知,见附录)
|
||||||
|
- `m_sessionType`: 会话类型
|
||||||
|
- `m_formula`: 0=F1 Modern, 1=F1 Classic, 2=F2, 3=F1 Generic, 4=Beta, 6=Esports, 8=F1 World, 9=F1 Elimination
|
||||||
|
- `m_safetyCarStatus`: 0=无, 1=实体安全车, 2=虚拟安全车, 3=编队圈
|
||||||
|
- `m_weatherForecastSamples[64]`: 天气预测(最多 64 样本,适配冲刺赛周末)
|
||||||
|
- `m_weekendStructure[12]`: 周末赛程结构
|
||||||
|
- `m_sector2LapDistanceStart` / `m_sector3LapDistanceStart`: 赛段起点距离
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Lap Data Packet(ID=2)
|
||||||
|
```c
|
||||||
|
struct LapData {
|
||||||
|
uint32 m_lastLapTimeInMS; // 上一圈时间 (ms)
|
||||||
|
uint32 m_currentLapTimeInMS; // 当前圈耗时 (ms)
|
||||||
|
uint16 m_sector1TimeMSPart; // S1 毫秒部分
|
||||||
|
uint8 m_sector1TimeMinutesPart; // S1 分钟部分
|
||||||
|
uint16 m_sector2TimeMSPart; // S2 毫秒部分
|
||||||
|
uint8 m_sector2TimeMinutesPart; // S2 分钟部分
|
||||||
|
uint16 m_deltaToCarInFrontMSPart; // 与前车差距毫秒
|
||||||
|
uint8 m_deltaToCarInFrontMinutesPart; // 与前车差距分钟
|
||||||
|
uint16 m_deltaToRaceLeaderMSPart; // 与领跑者差距毫秒
|
||||||
|
uint8 m_deltaToRaceLeaderMinutesPart; // 与领跑者差距分钟
|
||||||
|
float m_lapDistance; // 当前圈已跑距离 (m)
|
||||||
|
float m_totalDistance; // 总行驶距离 (m)
|
||||||
|
float m_safetyCarDelta; // 安全车时间差 (s)
|
||||||
|
uint8 m_carPosition; // 赛道排名
|
||||||
|
uint8 m_currentLapNum; // 当前圈数
|
||||||
|
uint8 m_pitStatus; // 0=无, 1=进站中, 2=维修区
|
||||||
|
uint8 m_numPitStops; // 已进站次数
|
||||||
|
uint8 m_sector; // 0=S1, 1=S2, 2=S3
|
||||||
|
uint8 m_currentLapInvalid; // 0=有效, 1=无效
|
||||||
|
uint8 m_penalties; // 累计罚时 (s)
|
||||||
|
uint8 m_driverStatus; // 0=车库, 1=飞行圈, 2=回场圈, 3=出场圈, 4=赛道上
|
||||||
|
uint8 m_resultStatus; // 0-7 (invalid/active/finished/DNF/DSQ等)
|
||||||
|
float m_speedTrapFastestSpeed; // 速度陷阱最快速度 (km/h)
|
||||||
|
// ... 等
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Event Packet(ID=3)
|
||||||
|
事件代码:
|
||||||
|
|
||||||
|
| 代码 | 事件 | 代码 | 事件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SSTA` | 会话开始 | `SEND` | 会话结束 |
|
||||||
|
| `FTLP` | 最快圈速 | `RTMT` | 退赛 |
|
||||||
|
| `DRSE` | DRS 启用 | `DRSD` | DRS 禁用 |
|
||||||
|
| `TMPT` | 队友进站 | `CHQF` | 方格旗 |
|
||||||
|
| `RCWN` | 冠军产生 | `PENA` | 处罚发出 |
|
||||||
|
| `SPTP` | 速度陷阱触发 | `STLG` | 发车灯 |
|
||||||
|
| `LGOT` | 灯灭起跑 | `DTSV` | Drive-through 已执行 |
|
||||||
|
| `SGSV` | Stop-Go 已执行 | `FLBK` | Flashback |
|
||||||
|
| `BUTN` | 按钮状态 | `RDFL` | 红旗 |
|
||||||
|
| `OVTK` | 超车 | `SCAR` | 安全车事件 |
|
||||||
|
| `COLL` | 碰撞事件 | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Car Telemetry Packet(ID=6)
|
||||||
|
```c
|
||||||
|
struct CarTelemetryData {
|
||||||
|
uint16 m_speed; // 速度 (km/h)
|
||||||
|
float m_throttle; // 油门 (0.0~1.0)
|
||||||
|
float m_steer; // 转向 (-1.0~1.0)
|
||||||
|
float m_brake; // 刹车 (0.0~1.0)
|
||||||
|
uint8 m_clutch; // 离合 (0~100)
|
||||||
|
int8 m_gear; // 档位 (1-8, N=0, R=-1)
|
||||||
|
uint16 m_engineRPM; // 引擎转速
|
||||||
|
uint8 m_drs; // DRS: 0=关, 1=开
|
||||||
|
uint8 m_revLightsPercent; // 转速灯百分比
|
||||||
|
uint16 m_revLightsBitValue; // 转速灯位值
|
||||||
|
uint16 m_brakesTemperature[4]; // 刹车温度 (°C)
|
||||||
|
uint8 m_tyresSurfaceTemperature[4]; // 胎面温度 (°C)
|
||||||
|
uint8 m_tyresInnerTemperature[4]; // 胎内温度 (°C)
|
||||||
|
uint16 m_engineTemperature; // 引擎温度 (°C)
|
||||||
|
float m_tyresPressure[4]; // 胎压 (PSI)
|
||||||
|
uint8 m_surfaceType[4]; // 路面类型
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Car Status Packet(ID=7)
|
||||||
|
```c
|
||||||
|
struct CarStatusData {
|
||||||
|
uint8 m_tractionControl; // 0=关, 1=中, 2=全
|
||||||
|
uint8 m_antiLockBrakes; // 0=关, 1=开
|
||||||
|
uint8 m_fuelMix; // 0=Lean, 1=Standard, 2=Rich, 3=Max
|
||||||
|
uint8 m_frontBrakeBias; // 前刹车比 (%)
|
||||||
|
float m_fuelInTank; // 燃油量 (kg)
|
||||||
|
float m_fuelCapacity; // 油箱容量
|
||||||
|
float m_fuelRemainingLaps; // 剩余油量可跑圈数
|
||||||
|
uint8 m_drsAllowed; // DRS 是否允许
|
||||||
|
uint16 m_drsActivationDistance; // DRS 激活距离 (m)
|
||||||
|
uint8 m_actualTyreCompound; // 实际轮胎配方 (16=C5, 17=C4, 18=C3, 19=C2, 20=C1, 21=C0, 7=半雨, 8=全雨)
|
||||||
|
uint8 m_visualTyreCompound; // 视觉轮胎配方 (16=软, 17=中, 18=硬)
|
||||||
|
uint8 m_tyresAgeLaps; // 轮胎使用圈数
|
||||||
|
int8 m_vehicleFiaFlags; // FIA 旗帜 (-1=未知, 0=无, 1=绿, 2=蓝, 3=黄)
|
||||||
|
float m_enginePowerICE; // 内燃机功率 (W)
|
||||||
|
float m_enginePowerMGUK; // MGU-K 功率 (W)
|
||||||
|
float m_ersStoreEnergy; // ERS 储能 (J)
|
||||||
|
uint8 m_ersDeployMode; // 0=无, 1=Medium, 2=Hotlap, 3=Overtake
|
||||||
|
float m_ersHarvestedThisLapMGUK; // MGU-K 回收能量
|
||||||
|
float m_ersHarvestedThisLapMGUH; // MGU-H 回收能量
|
||||||
|
float m_ersDeployedThisLap; // 已使用 ERS 能量
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Car Damage Packet(ID=10)
|
||||||
|
```c
|
||||||
|
struct CarDamageData {
|
||||||
|
float m_tyresWear[4]; // 轮胎磨损 (%)
|
||||||
|
uint8 m_tyresDamage[4]; // 轮胎损伤 (%)
|
||||||
|
uint8 m_brakesDamage[4]; // 刹车损伤 (%)
|
||||||
|
uint8 m_frontLeftWingDamage; // 前左翼损伤 (%)
|
||||||
|
uint8 m_frontRightWingDamage; // 前右翼损伤 (%)
|
||||||
|
uint8 m_rearWingDamage; // 尾翼损伤 (%)
|
||||||
|
uint8 m_floorDamage; // 底板损伤 (%)
|
||||||
|
uint8 m_diffuserDamage; // 扩散器损伤 (%)
|
||||||
|
uint8 m_sidepodDamage; // 侧箱损伤 (%)
|
||||||
|
uint8 m_drsFault; // DRS 故障 (0/1)
|
||||||
|
uint8 m_ersFault; // ERS 故障 (0/1)
|
||||||
|
uint8 m_gearBoxDamage; // 变速箱损伤 (%)
|
||||||
|
uint8 m_engineDamage; // 引擎损伤 (%)
|
||||||
|
uint8 m_engineMGUHWear; // MGU-H 磨损 (%)
|
||||||
|
uint8 m_engineESWear; // ES 磨损 (%)
|
||||||
|
uint8 m_engineCEWear; // CE 磨损 (%)
|
||||||
|
uint8 m_engineICEWear; // ICE 磨损 (%)
|
||||||
|
uint8 m_engineMGUKWear; // MGU-K 磨损 (%)
|
||||||
|
uint8 m_engineTCWear; // TC 磨损 (%)
|
||||||
|
uint8 m_engineBlown; // 引擎爆缸 (0/1)
|
||||||
|
uint8 m_engineSeized; // 引擎卡死 (0/1)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Motion Ex Packet(ID=13)— 运动平台关键包
|
||||||
|
|
||||||
|
专为 **运动模拟平台(D-BOX 等)** 设计,包含更多细节:
|
||||||
|
|
||||||
|
```c
|
||||||
|
float m_suspensionPosition[4]; // 悬挂位置 [RL, RR, FL, FR]
|
||||||
|
float m_suspensionVelocity[4]; // 悬挂速度
|
||||||
|
float m_suspensionAcceleration[4]; // 悬挂加速度
|
||||||
|
float m_wheelSpeed[4]; // 各轮速度
|
||||||
|
float m_wheelSlipRatio[4]; // 滑移率
|
||||||
|
float m_wheelSlipAngle[4]; // 滑移角
|
||||||
|
float m_wheelLatForce[4]; // 横向力
|
||||||
|
float m_wheelLongForce[4]; // 纵向力
|
||||||
|
float m_wheelVertForce[4]; // 垂向力
|
||||||
|
float m_heightOfCOGAboveGround; // 重心高度
|
||||||
|
float m_localVelocityX/Y/Z; // 局部速度
|
||||||
|
float m_angularVelocityX/Y/Z; // 角速度 (rad/s)
|
||||||
|
float m_angularAccelerationX/Y/Z; // 角加速度 (rad/s²)
|
||||||
|
float m_frontWheelsAngle; // 前轮转角 (rad)
|
||||||
|
float m_frontAeroHeight; // 前底盘高度
|
||||||
|
float m_rearAeroHeight; // 后底盘高度
|
||||||
|
float m_frontRollAngle; // 前悬挂侧倾角
|
||||||
|
float m_rearRollAngle; // 后悬挂侧倾角
|
||||||
|
float m_chassisYaw; // 底盘偏航角 (rad) — 可用于判断侧滑
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 其余包简介
|
||||||
|
|
||||||
|
| 包 | 关键内容 |
|
||||||
|
|---|---|
|
||||||
|
| **Final Classification (8)** | 最终排名、圈数、积分、最佳圈速、轮胎使用记录 |
|
||||||
|
| **Lobby Info (9)** | 多人游戏大厅的玩家信息 |
|
||||||
|
| **Session History (11)** | 每辆车的全部圈速历史 + 轮胎使用历史(最多 100 圈) |
|
||||||
|
| **Tyre Sets (12)** | 轮胎组详情:配方、磨损度、可用性、圈速差等 |
|
||||||
|
| **Time Trial (14)** | 计时赛专属:玩家最佳、个人最佳、对手数据 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 隐私:Your Telemetry 设置
|
||||||
|
|
||||||
|
当玩家设置 `Your Telemetry = Restricted` 时,以下数据对其他玩家**归零**:
|
||||||
|
- **Car Status**:燃油、ERS 相关数据
|
||||||
|
- **Car Damage**:所有损伤数据
|
||||||
|
- **Tyre Sets**:全部轮胎组数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 F1 24 相比 F1 23 的主要变化
|
||||||
|
|
||||||
|
1. 会话包中新增模拟/规则选项
|
||||||
|
2. 新增安全车事件详情(`SCAR`)
|
||||||
|
3. 大厅和参与者包中新增在线名称、Your Telemetry、F1 World Tech Level
|
||||||
|
4. 圈速包中新增与前车/领先者的分钟差距字段
|
||||||
|
5. 最终排名包改为结果界面每 5 秒重复发送
|
||||||
|
6. 新增 **Time Trial 包**(ID=14)
|
||||||
|
7. Motion Ex 包新增底盘高度(plank height)、悬挂侧倾角、底盘偏航角
|
||||||
|
8. 新增碰撞事件(`COLL`)
|
||||||
|
9. 新增周末赛程结构(`m_weekendStructure`)
|
||||||
|
10. 天气预测数组从 56 扩展到 64
|
||||||
|
11. 圈速包新增速度陷阱数据
|
||||||
|
12. Car Setup 包新增发动机制动参数 + 下次进站前翼值
|
||||||
+1
-11
@@ -238,12 +238,6 @@
|
|||||||
- **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节**
|
- **FH5**:约 74 个字段(比 FH6 少 CarGroup/SmashableVelDiff/SmashableMass),总计 **约 311 字节**
|
||||||
- **FH4**:和 FH5 基本一致
|
- **FH4**:和 FH5 基本一致
|
||||||
|
|
||||||
好问题!您确实很难在游戏中见到 P 档和 N 档,因为正常驾驶时它们几乎不出现。让我查一下社区中关于这个细节的讨论。
|
|
||||||
|
|
||||||
让我搜索更详细的信息。让我再搜一下 GitHub 上关于这个问题的实际讨论。让我直接看看社区中资深开发者对 Gear 字段的完整说明。
|
|
||||||
|
|
||||||
根据搜索结果和实际代码分析,以下是关于 **N 档(Neutral)** 和 **P 档(Park)** 在 FH4/FH5/FH6 UDP 数据包中的完整定义:
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gear 字段的完整取值范围
|
## Gear 字段的完整取值范围
|
||||||
@@ -259,11 +253,7 @@
|
|||||||
| **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 |
|
| **21** | **P 档(Park,驻车档)** | 赛车完全静止时极罕见触发 |
|
||||||
| **22+** | 理论上可能更高(如 D 档标记) | 极少见 |
|
| **22+** | 理论上可能更高(如 D 档标记) | 极少见 |
|
||||||
|
|
||||||
### 关键发现:P 档 = 21
|
### Gear 值说明
|
||||||
|
|
||||||
社区中一位资深开发者(*0x20F*,Forza Telemetry 核心开发者)明确指出:
|
|
||||||
|
|
||||||
> **Gear 21 = P (Park)**
|
|
||||||
|
|
||||||
这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。
|
这个值在正常游戏中几乎不会出现,因为《极限竞速:地平线》本质上没有"驻车"机制——汽车要么在跑,要么完全静止时引擎仍在空档(Gear=0)。但极少数情况下(如某些特殊漂移状态下),游戏引擎会短暂报告 Gear=21。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# 📡 iRacing Telemetry API(IRSDK)完整规范
|
||||||
|
|
||||||
|
## 一、概述
|
||||||
|
|
||||||
|
iRacing 的遥测系统通过 **共享内存映射文件(Memory-Mapped File)** 工作,所有数据是**只读**的。API 版本当前为 **IRSDK_VER = 2**。
|
||||||
|
|
||||||
|
### 三种数据通道
|
||||||
|
|
||||||
|
| 通道 | 形式 | 频率 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Live Telemetry** | 共享内存 `Local\IRSDKMemMapFileName` | 60Hz(或 360Hz) | 实时变量数据,三缓冲机制 |
|
||||||
|
| **Session String** | 共享内存中的 YAML 字符串 | 变更时更新 | 会话元数据(赛道、车手、天气等) |
|
||||||
|
| **Disk Telemetry (.ibt)** | 二进制文件 | 60Hz | 按 `Alt+L` 录制,结构等同于 Live 数据 |
|
||||||
|
|
||||||
|
### 启用遥测
|
||||||
|
|
||||||
|
在 iRacing 的 `app.ini` 中确认:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[External Telemetry]
|
||||||
|
enableTelemetry=1 # 启用遥测
|
||||||
|
enableIRSDKTelemetry=1 # 启用 IRSDK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心架构
|
||||||
|
|
||||||
|
### 共享内存布局
|
||||||
|
|
||||||
|
```
|
||||||
|
[irsdk_header]
|
||||||
|
↓
|
||||||
|
[irsdk_varHeader[0] ... irsdk_varHeader[numVars-1]]
|
||||||
|
↓
|
||||||
|
[Session Info YAML String]
|
||||||
|
↓
|
||||||
|
[irsdk_varBuf[0]] → [Telemetry Data Line 0]
|
||||||
|
[irsdk_varBuf[1]] → [Telemetry Data Line 1]
|
||||||
|
[irsdk_varBuf[2]] → [Telemetry Data Line 2]
|
||||||
|
[irsdk_varBuf[3]] → [Telemetry Data Line 3] (IRSDK_MAX_BUFS = 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **三缓冲**:模拟器写满一个缓冲区后立即切到下一个,给客户端 16ms+ 窗口来读取
|
||||||
|
- **同步事件**:`Local\IRSDKDataValidEvent` — 新数据就绪时触发
|
||||||
|
|
||||||
|
### 字节序与编码
|
||||||
|
|
||||||
|
- **字节序**:Little Endian
|
||||||
|
- **YAML 编码**:UTF-8
|
||||||
|
- **对齐**:16 字节对齐
|
||||||
|
- **变量名最大长**:`IRSDK_MAX_STRING = 32`
|
||||||
|
- **描述最大长**:`IRSDK_MAX_DESC = 64`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据结构
|
||||||
|
|
||||||
|
### 1. irsdk_header(主头部)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_header {
|
||||||
|
int ver; // API 版本 = 2
|
||||||
|
int status; // 位字段 (irsdk_stConnected = 1)
|
||||||
|
int tickRate; // tick 频率 (60 或 360)
|
||||||
|
|
||||||
|
// 会话信息(变更时更新)
|
||||||
|
int sessionInfoUpdate; // 会话信息变更计数
|
||||||
|
int sessionInfoLen; // YAML 字符串长度(字节)
|
||||||
|
int sessionInfoOffset; // 距头部的偏移
|
||||||
|
|
||||||
|
// 遥测变量
|
||||||
|
int numVars; // 变量数量
|
||||||
|
int varHeaderOffset; // varHeader 数组偏移
|
||||||
|
int numBuf; // 缓冲区数量(4)
|
||||||
|
int bufLen; // 每行字节数
|
||||||
|
int pad1[2]; // 16 字节对齐
|
||||||
|
|
||||||
|
irsdk_varBuf varBuf[IRSDK_MAX_BUFS]; // 4 个缓冲区
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. irsdk_varHeader(变量描述头)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_varHeader {
|
||||||
|
int type; // irsdk_VarType 枚举
|
||||||
|
int offset; // 在缓冲区行中的偏移
|
||||||
|
int count; // 数组元素数(标量 = 1)
|
||||||
|
bool countAsTime;
|
||||||
|
char pad[3]; // 16 字节对齐
|
||||||
|
|
||||||
|
char name[IRSDK_MAX_STRING]; // 变量名,如 "Speed"
|
||||||
|
char desc[IRSDK_MAX_DESC]; // 描述,如 "GPS vehicle speed"
|
||||||
|
char unit[IRSDK_MAX_STRING]; // 单位,如 "m/s"
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. irsdk_varBuf(数据缓冲区)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_varBuf {
|
||||||
|
int tickCount; // 检测数据更新
|
||||||
|
int bufOffset; // 距头部的偏移
|
||||||
|
int pad[2]; // 16 字节对齐
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. irsdk_VarType(变量类型)
|
||||||
|
|
||||||
|
| 枚举值 | 类型 | 字节数 |
|
||||||
|
|---|---|---|
|
||||||
|
| `irsdk_char = 0` | char | 1 |
|
||||||
|
| `irsdk_bool` | bool | 1 |
|
||||||
|
| `irsdk_int` | int32 | 4 |
|
||||||
|
| `irsdk_bitField` | bitfield (int32) | 4 |
|
||||||
|
| `irsdk_float` | float32 | 4 |
|
||||||
|
| `irsdk_double` | float64 | 8 |
|
||||||
|
|
||||||
|
### 5. irsdk_diskSubHeader(仅 .ibt 文件)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct irsdk_diskSubHeader {
|
||||||
|
time_t sessionStartDate;
|
||||||
|
double sessionStartTime;
|
||||||
|
double sessionEndTime;
|
||||||
|
int sessionLapCount;
|
||||||
|
int sessionRecordCount;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Session String(YAML 结构)
|
||||||
|
|
||||||
|
YAML 字符串通过 `irsdk_getSessionInfoStr()` 获取,包含以下顶级节点:
|
||||||
|
|
||||||
|
### WeekendInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
WeekendInfo:
|
||||||
|
TrackName: "suzuka"
|
||||||
|
TrackID: 123
|
||||||
|
TrackLength: "5807 m"
|
||||||
|
TrackDisplayName: "Suzuka International Racing Course"
|
||||||
|
TrackCity: "Suzuka"
|
||||||
|
TrackCountry: "JPN"
|
||||||
|
TrackNumTurns: 18
|
||||||
|
TrackPitSpeedLimit: "80 kph"
|
||||||
|
TrackType: "road"
|
||||||
|
TrackWeatherType: "Dynamic"
|
||||||
|
TrackSkies: "Partly Cloudy"
|
||||||
|
TrackSurfaceTemp: "38 C"
|
||||||
|
TrackAirTemp: "25 C"
|
||||||
|
TrackWindVel: "3 m/s"
|
||||||
|
TrackWindDir: "180 deg"
|
||||||
|
TrackRelativeHumidity: "55 %"
|
||||||
|
TrackFogLevel: "0 %"
|
||||||
|
SeriesID: 100
|
||||||
|
SeasonID: 2024
|
||||||
|
SessionID: 456
|
||||||
|
SubSessionID: 789
|
||||||
|
LeagueID: 0
|
||||||
|
Official: 1
|
||||||
|
RaceWeek: 5
|
||||||
|
EventType: "Race"
|
||||||
|
Category: "Road"
|
||||||
|
SimMode: "full"
|
||||||
|
TeamRacing: 0
|
||||||
|
MinDrivers: 6
|
||||||
|
MaxDrivers: 26
|
||||||
|
NumCarClasses: 1
|
||||||
|
NumCarTypes: 3
|
||||||
|
WeekendOptions:
|
||||||
|
NumStarters: 20
|
||||||
|
StartingGrid: "2x2"
|
||||||
|
QualifyScoring: "single"
|
||||||
|
CourseCautions: "full"
|
||||||
|
StandingStart: 0
|
||||||
|
Restarts: "singleFile"
|
||||||
|
WeatherType: "Dynamic"
|
||||||
|
Skies: "Partly Cloudy"
|
||||||
|
WindDirection: "180 deg"
|
||||||
|
WindSpeed: "3 m/s"
|
||||||
|
WeatherTemp: "25 C"
|
||||||
|
RelativeHumidity: "55 %"
|
||||||
|
FogLevel: "0 %"
|
||||||
|
Unofficial: 0
|
||||||
|
IsFixedSetup: 0
|
||||||
|
HardcoreLevel: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
SessionInfo:
|
||||||
|
Sessions:
|
||||||
|
- SessionNum: 0
|
||||||
|
SessionLaps: "unlimited"
|
||||||
|
SessionTime: "30 min"
|
||||||
|
SessionType: "Practice"
|
||||||
|
ResultsPositions: []
|
||||||
|
ResultsFastestLap: []
|
||||||
|
- SessionNum: 1
|
||||||
|
SessionLaps: "2 laps"
|
||||||
|
SessionTime: "10 min"
|
||||||
|
SessionType: "Lone Qualify"
|
||||||
|
- SessionNum: 2
|
||||||
|
SessionLaps: "12 laps"
|
||||||
|
SessionTime: "30 min"
|
||||||
|
SessionType: "Race"
|
||||||
|
```
|
||||||
|
|
||||||
|
### DriverInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
DriverInfo:
|
||||||
|
DriverCarIdx: 0
|
||||||
|
DriverCarIdleRPM: 3500.0
|
||||||
|
DriverCarRedLine: 9500.0
|
||||||
|
DriverCarSLFirstRPM: 8500.0
|
||||||
|
DriverCarSLShiftRPM: 9000.0
|
||||||
|
DriverCarSLLastRPM: 9500.0
|
||||||
|
DriverCarSLBlinkRPM: 9200.0
|
||||||
|
Drivers:
|
||||||
|
- CarIdx: 0
|
||||||
|
UserName: "John Doe"
|
||||||
|
AbbrevName: "J. Doe"
|
||||||
|
Initials: "JD"
|
||||||
|
CarNumber: "23"
|
||||||
|
CarPath: "mercedesamggt3"
|
||||||
|
CarClassID: 1
|
||||||
|
IRating: 2500
|
||||||
|
LicString: "A 4.99"
|
||||||
|
IsSpectator: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### CameraInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
CameraInfo:
|
||||||
|
Groups:
|
||||||
|
- GroupNum: 1
|
||||||
|
GroupName: "TV1"
|
||||||
|
Cameras:
|
||||||
|
- CameraNum: 1
|
||||||
|
CameraName: "TV1_Turn1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SplitTimeInfo
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
SplitTimeInfo:
|
||||||
|
Sectors:
|
||||||
|
- SectorNum: 0
|
||||||
|
SectorStartPct: 0.0
|
||||||
|
- SectorNum: 1
|
||||||
|
SectorStartPct: 0.35
|
||||||
|
- SectorNum: 2
|
||||||
|
SectorStartPct: 0.70
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心遥测变量列表(完整)
|
||||||
|
|
||||||
|
### 🏎️ 玩家车辆控制
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SteeringWheelAngle` | float | rad | 方向盘转角 |
|
||||||
|
| `Throttle` | float | % (0-1) | 油门(处理后的) |
|
||||||
|
| `Brake` | float | % (0-1) | 刹车(处理后的) |
|
||||||
|
| `Clutch` | float | % (0-1) | 离合 |
|
||||||
|
| `Gear` | int | — | -1=R,0=N,1~n=前进档 |
|
||||||
|
| `RPM` | float | revs/min | 引擎转速 |
|
||||||
|
| `ThrottleRaw` | float | % (0-1) | 原始油门输入 |
|
||||||
|
| `BrakeRaw` | float | % (0-1) | 原始刹车输入 |
|
||||||
|
| `HandbrakeRaw` | float | % (0-1) | 原始手刹输入 |
|
||||||
|
| `Speed` | float | m/s | GPS 车速 |
|
||||||
|
| `OnPitRoad` | bool | — | 是否在维修区通道 |
|
||||||
|
|
||||||
|
### 📐 位置与运动
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Yaw` | float | rad | 偏航角 |
|
||||||
|
| `YawNorth` | float | rad | 相对北方的偏航角 |
|
||||||
|
| `Pitch` | float | rad | 俯仰角 |
|
||||||
|
| `Roll` | float | rad | 侧倾角 |
|
||||||
|
| `YawRate` / `YawRate_ST` | float | rad/s | 偏航率(ST=子tick精度) |
|
||||||
|
| `PitchRate` / `PitchRate_ST` | float | rad/s | 俯仰率 |
|
||||||
|
| `RollRate` / `RollRate_ST` | float | rad/s | 侧倾率 |
|
||||||
|
| `VelocityX` / `VelocityX_ST` | float | m/s | X 方向速度 |
|
||||||
|
| `VelocityY` / `VelocityY_ST` | float | m/s | Y 方向速度 |
|
||||||
|
| `VelocityZ` / `VelocityZ_ST` | float | m/s | Z 方向速度 |
|
||||||
|
| `LatAccel` / `LatAccel_ST` | float | m/s² | 横向加速度 |
|
||||||
|
| `LongAccel` / `LongAccel_ST` | float | m/s² | 纵向加速度 |
|
||||||
|
| `VertAccel` / `VertAccel_ST` | float | m/s² | 垂向加速度 |
|
||||||
|
|
||||||
|
> `_ST` 后缀变量提供 **子 tick 精度**,适合运动平台等精密场景。
|
||||||
|
|
||||||
|
### ⏱️ 圈速与计时
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Lap` | int | — | 当前圈号 |
|
||||||
|
| `LapCompleted` | int | — | 已完成圈数 |
|
||||||
|
| `LapDist` | float | m | 当前圈已跑距离 |
|
||||||
|
| `LapDistPct` | float | % | 当前圈完成百分比 |
|
||||||
|
| `RaceLaps` | int | — | 比赛中已完成圈数 |
|
||||||
|
| `LapBestLap` | int | — | 最佳圈号 |
|
||||||
|
| `LapBestLapTime` | float | s | 最佳圈速 |
|
||||||
|
| `LapLastLapTime` | float | s | 上一圈用时 |
|
||||||
|
| `LapCurrentLapTime` | float | s | 当前圈用时(F3 黑盒) |
|
||||||
|
| `LapBestNLapLap` | int | — | 最佳 N 圈均值的最后一圈 |
|
||||||
|
| `LapBestNLapTime` | float | s | 最佳 N 圈平均时间 |
|
||||||
|
| `LapLastNLapTime` | float | s | 最近 N 圈平均时间 |
|
||||||
|
| `LapLasNLapSeq` | int | — | 连续干净圈数 |
|
||||||
|
|
||||||
|
### 🎯 Delta 时间
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `LapDeltaToBestLap` | float | s | 与个人最佳圈的差距 |
|
||||||
|
| `LapDeltaToBestLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToBestLap_OK` | bool | — | delta 数据是否有效 |
|
||||||
|
| `LapDeltaToOptimalLap` | float | s | 与理论最佳圈的差距 |
|
||||||
|
| `LapDeltaToOptimalLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToOptimalLap_OK` | bool | — | 是否有效 |
|
||||||
|
| `LapDeltaToSessionBestLap` | float | s | 与全场最佳圈差距 |
|
||||||
|
| `LapDeltaToSessionBestLap_DD` | float | s/s | 差距变化率 |
|
||||||
|
| `LapDeltaToSessionBestLap_OK` | bool | — | 是否有效 |
|
||||||
|
| `LapDeltaToSessionOptimalLap` | float | s | 与全场理论最佳差距 |
|
||||||
|
| `LapDeltaToSessionLastlLap` | float | s | 与全场上一圈差距 |
|
||||||
|
|
||||||
|
### ⛽ 引擎 & 燃油
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `FuelLevel` | float | L | 剩余油量(升) |
|
||||||
|
| `FuelLevelPct` | float | % | 剩余油量百分比 |
|
||||||
|
| `FuelPress` | float | bar | 燃油压力 |
|
||||||
|
| `FuelUsePerHour` | float | kg/h | 瞬时油耗率 |
|
||||||
|
| `ManifoldPress` | float | bar | 进气歧管压力 |
|
||||||
|
| `OilLevel` | float | L | 机油液位 |
|
||||||
|
| `OilPress` | float | bar | 机油压力 |
|
||||||
|
| `OilTemp` | float | °C | 机油温度 |
|
||||||
|
| `WaterLevel` | float | L | 冷却液液位 |
|
||||||
|
| `WaterTemp` | float | °C | 冷却液温度 |
|
||||||
|
| `Voltage` | float | V | 电压 |
|
||||||
|
| `EngineWarnings` | bitfield | — | 引擎警告灯(见下文) |
|
||||||
|
| `BrakeABSactive` | bool | — | ABS 是否激活 |
|
||||||
|
|
||||||
|
### 🛞 轮胎数据(每轮 LF/RF/LR/RR)
|
||||||
|
|
||||||
|
设 `X` ∈ {LF, RF, LR, RR}:
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `XcoldPressure` | float | kPa | 冷胎压 |
|
||||||
|
| `Xpressure` | float | kPa | 当前胎压 |
|
||||||
|
| `XtempCL/M/R` | float | °C | 胎面左/中/右温度 |
|
||||||
|
| `XwearL/M/R` | float | % | 胎面左/中/右磨损 |
|
||||||
|
| `XbrakeLinePress` | float | bar | 刹车管压力 |
|
||||||
|
| `XshockDefl` | float | mm | 悬挂压缩量 |
|
||||||
|
| `XshockVel` | float | mm/s | 悬挂速度 |
|
||||||
|
| `XrideHeight` | float | mm | 行驶高度 |
|
||||||
|
| `Xspeed` | float | rad/s | 轮速 |
|
||||||
|
|
||||||
|
### 🎛️ 力反馈
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SteeringWheelTorque` | float | N·m | 转向轴输出扭矩 |
|
||||||
|
| `SteeringWheelTorque_ST` | float | N·m | 转向轴扭矩(子tick) |
|
||||||
|
| `SteeringWheelPctTorque` | float | % | FFB 扭矩百分比(无符号) |
|
||||||
|
| `SteeringWheelPctTorqueSign` | float | % | FFB 扭矩百分比(有符号) |
|
||||||
|
| `SteeringWheelPctTorqueSignStops` | float | % | FFB 扭矩百分比(含止挡) |
|
||||||
|
| `SteeringWheelPctDamper` | float | % | FFB 阻尼百分比 |
|
||||||
|
| `SteeringWheelAngleMax` | float | rad | 方向盘最大转角 |
|
||||||
|
| `SteeringWheelPeakForceNm` | float | N·m | FFB 峰值力映射 |
|
||||||
|
| `SteeringWheelMaxForceNm` | float | N·m | 最大力 |
|
||||||
|
| `SteeringWheelUseLinear` | bool | — | 是否线性模式 |
|
||||||
|
|
||||||
|
### 🏁 会话状态
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SessionTime` | double | s | 当前会话已过时间 |
|
||||||
|
| `SessionTimeRemain` | double | s | 会话剩余时间 |
|
||||||
|
| `SessionTimeTotal` | double | s | 会话总时间 |
|
||||||
|
| `SessionLapsRemain` | int | — | 剩余圈数 |
|
||||||
|
| `SessionLapsRemainEx` | int | — | 扩展剩余圈数 |
|
||||||
|
| `SessionLapsTotal` | int | — | 总圈数 |
|
||||||
|
| `SessionNum` | int | — | 会话序号 |
|
||||||
|
| `SessionState` | int | — | 会话状态(见枚举) |
|
||||||
|
| `SessionUniqueID` | int | — | 会话唯一 ID |
|
||||||
|
| `SessionFlags` | bitfield | — | 旗语(见下文) |
|
||||||
|
| `SessionTick` | int | — | Tick 计数 |
|
||||||
|
| `SessionTimeOfDay` | float | s | 赛道时间 |
|
||||||
|
|
||||||
|
### 🚩 旗语 & 状态
|
||||||
|
|
||||||
|
**SessionFlags** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x00000001` | 🏁 方格旗 |
|
||||||
|
| `0x00000002` | ⬜ 白旗 |
|
||||||
|
| `0x00000004` | 🟩 绿旗 |
|
||||||
|
| `0x00000008` | 🟨 黄旗 |
|
||||||
|
| `0x00000010` | 🟥 红旗 |
|
||||||
|
| `0x00000020` | 🟦 蓝旗 |
|
||||||
|
| `0x00000040` | 💥 碎片 |
|
||||||
|
| `0x00000100` | 🟨 黄旗挥动 |
|
||||||
|
| `0x00000200` | 绿旗前最后一圈 |
|
||||||
|
| `0x00004000` | ⚠️ Full Course Caution |
|
||||||
|
| `0x00010000` | ⬛ 黑旗 |
|
||||||
|
| `0x00020000` | ❌ DSQ |
|
||||||
|
| `0x10000000` | 🔴 发车灯隐藏 |
|
||||||
|
| `0x20000000` | 🔴 发车灯就绪 |
|
||||||
|
| `0x40000000` | 🔴 发车灯 Set |
|
||||||
|
| `0x80000000` | 🟢 发车灯 Go |
|
||||||
|
|
||||||
|
**EngineWarnings** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x0001` | 水温警告 |
|
||||||
|
| `0x0002` | 燃油压力警告 |
|
||||||
|
| `0x0004` | 机油压力警告 |
|
||||||
|
| `0x0008` | 引擎熄火 |
|
||||||
|
| `0x0010` | Pit Speed Limiter |
|
||||||
|
| `0x0020` | 转速限制器激活 |
|
||||||
|
| `0x0040` | 油温警告 |
|
||||||
|
|
||||||
|
### 🚗 多车数据(CarIdx 数组,最多 64 辆车)
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CarIdxLap` | int[] | 每辆车的圈数 |
|
||||||
|
| `CarIdxLapCompleted` | int[] | 每辆车已完成圈数 |
|
||||||
|
| `CarIdxLapDistPct` | float[] | 每辆车的位置百分比 |
|
||||||
|
| `CarIdxPosition` | int[] | 每辆车的排名 |
|
||||||
|
| `CarIdxClassPosition` | int[] | 组别排名 |
|
||||||
|
| `CarIdxClass` | int[] | 组别 ID |
|
||||||
|
| `CarIdxF2Time` | float[] | 与领先者的差距(或最快圈速) |
|
||||||
|
| `CarIdxEstTime` | float[] | 预计到达当前位置的时间 |
|
||||||
|
| `CarIdxLastLapTime` | float[] | 上一圈时间 |
|
||||||
|
| `CarIdxBestLapTime` | float[] | 最佳圈时间 |
|
||||||
|
| `CarIdxBestLapNum` | int[] | 最佳圈号 |
|
||||||
|
| `CarIdxSteer` | float[] | 方向盘转角 |
|
||||||
|
| `CarIdxRPM` | float[] | 引擎转速 |
|
||||||
|
| `CarIdxGear` | int[] | 档位 |
|
||||||
|
| `CarIdxOnPitRoad` | bool[] | 是否在维修区 |
|
||||||
|
| `CarIdxTrackSurface` | int[] | 赛道位置类型 |
|
||||||
|
| `CarIdxTrackSurfaceMaterial` | int[] | 路面材质 |
|
||||||
|
| `CarIdxTireCompound` | int[] | 轮胎配方 |
|
||||||
|
| `CarIdxP2P_Status` | bool[] | Push-to-Pass 状态 |
|
||||||
|
| `CarIdxP2P_Count` | int[] | Push-to-Pass 剩余次数 |
|
||||||
|
|
||||||
|
### 🛑 进站 & 维修
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PitsOpen` | bool | — | 维修区是否开放 |
|
||||||
|
| `PitstopActive` | bool | — | 正在进站 |
|
||||||
|
| `PitRepairLeft` | float | s | 强制维修剩余时间 |
|
||||||
|
| `PitOptRepairLeft` | float | s | 可选维修剩余时间 |
|
||||||
|
| `PitSvFlags` | bitfield | — | 进站服务勾选 |
|
||||||
|
| `PitSvLFP/RFP/LRP/RRP` | float | kPa | 指定胎压 |
|
||||||
|
| `PitSvFuel` | float | L | 指定加油量 |
|
||||||
|
| `PitSvTireCompound` | int | — | 指定轮胎配方 |
|
||||||
|
| `FastRepairUsed` | int | — | 已用快速维修次数 |
|
||||||
|
| `FastRepairAvailable` | int | — | 可用快速维修次数 |
|
||||||
|
| `PlayerCarInPitStall` | bool | — | 玩家在维修位 |
|
||||||
|
| `PlayerCarPitSvStatus` | int | — | 进站服务状态 |
|
||||||
|
|
||||||
|
**PitSvFlags** 位字段:
|
||||||
|
|
||||||
|
| 位标记 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `0x0001` | 左前换胎 |
|
||||||
|
| `0x0002` | 右前换胎 |
|
||||||
|
| `0x0004` | 左后换胎 |
|
||||||
|
| `0x0008` | 右后换胎 |
|
||||||
|
| `0x0010` | 加油 |
|
||||||
|
| `0x0020` | 撕膜 |
|
||||||
|
| `0x0040` | 快速维修 |
|
||||||
|
|
||||||
|
### 🌦️ 天气 & 环境
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 单位 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `TrackTemp` | float | °C | 赛道温度(起终点) |
|
||||||
|
| `TrackTempCrew` | float | °C | 赛道温度(Pit 区) |
|
||||||
|
| `TrackWetness` | float | — | 赛道湿度级别 |
|
||||||
|
| `AirTemp` | float | °C | 空气温度 |
|
||||||
|
| `AirDensity` | float | kg/m³ | 空气密度 |
|
||||||
|
| `AirPressure` | float | Hg | 气压 |
|
||||||
|
| `WindVel` | float | m/s | 风速 |
|
||||||
|
| `WindDir` | float | rad | 风向 |
|
||||||
|
| `RelativeHumidity` | float | % | 相对湿度 |
|
||||||
|
| `FogLevel` | float | % | 雾级别 |
|
||||||
|
| `WeatherType` | int | — | 0=恒定,1=动态 |
|
||||||
|
| `Skies` | int | — | 0=晴,1=少云,2=多云,3=阴 |
|
||||||
|
|
||||||
|
### 🎥 回放 & 系统
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `IsReplayPlaying` | bool | 是否回放中 |
|
||||||
|
| `ReplayFrameNum` | int | 回放当前帧号 |
|
||||||
|
| `ReplayFrameNumEnd` | int | 回放末尾帧号 |
|
||||||
|
| `ReplayPlaySpeed` | int | 回放速度 |
|
||||||
|
| `ReplayPlaySlowMotion` | bool | 是否慢动作 |
|
||||||
|
| `ReplaySessionTime` | double | 回放中的会话时间 |
|
||||||
|
| `ReplaySessionNum` | int | 回放会话序号 |
|
||||||
|
| `IsOnTrack` | bool | 玩家是否在赛道上 |
|
||||||
|
| `IsInGarage` | bool | 是否在车库 |
|
||||||
|
| `IsDiskLoggingEnabled` | bool | 磁盘遥测是否开启 |
|
||||||
|
| `IsDiskLoggingActive` | bool | 是否正在录制 .ibt |
|
||||||
|
|
||||||
|
### 👥 车手变更(Team Racing)
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `DCLapStatus` | int | Driver Change 圈数状态 |
|
||||||
|
| `DCDriversSoFar` | int | 已驾驶过的车手数 |
|
||||||
|
|
||||||
|
### 📷 摄像机
|
||||||
|
|
||||||
|
| 变量名 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `CamCarIdx` | int | 当前焦点车辆索引 |
|
||||||
|
| `CamCameraNumber` | int | 当前摄像机编号 |
|
||||||
|
| `CamGroupNumber` | int | 当前摄像机组编号 |
|
||||||
|
| `CamCameraState` | bitfield | 摄像机状态位字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、重要枚举汇总
|
||||||
|
|
||||||
|
### SessionState
|
||||||
|
|
||||||
|
| 值 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | StateInvalid |
|
||||||
|
| 1 | StateGetInCar(上车中) |
|
||||||
|
| 2 | StateWarmup(暖胎圈) |
|
||||||
|
| 3 | StateParadeLaps(编队圈) |
|
||||||
|
| 4 | **StateRacing**(正赛中) |
|
||||||
|
| 5 | StateCheckered(方格旗) |
|
||||||
|
| 6 | StateCoolDown(冷却圈) |
|
||||||
|
|
||||||
|
### TrkLoc(赛道位置)
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| -1 | NotInWorld |
|
||||||
|
| 0 | OffTrack |
|
||||||
|
| 1 | InPitStall(维修区) |
|
||||||
|
| 2 | AproachingPits |
|
||||||
|
| 3 | OnTrack |
|
||||||
|
|
||||||
|
### TrkSurf(路面材质)
|
||||||
|
|
||||||
|
| 值 | 材质 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Undefined |
|
||||||
|
| 1-4 | Asphalt |
|
||||||
|
| 5-6 | Concrete |
|
||||||
|
| 7-8 | Racing Dirt |
|
||||||
|
| 9-10 | Paint |
|
||||||
|
| 11-14 | Rumble Strip(路肩) |
|
||||||
|
| 15-18 | Grass |
|
||||||
|
| 19-22 | Dirt |
|
||||||
|
| 23 | Sand |
|
||||||
|
| 24-25 | Gravel |
|
||||||
|
| 26 | Grasscrete |
|
||||||
|
| 27 | Astroturf |
|
||||||
|
|
||||||
|
### TrackWetness(赛道湿度)
|
||||||
|
|
||||||
|
| 值 | 级别 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Unknown |
|
||||||
|
| 1 | Dry |
|
||||||
|
| 2 | Mostly Dry |
|
||||||
|
| 3 | Very Lightly Wet |
|
||||||
|
| 4 | Lightly Wet |
|
||||||
|
| 5 | Moderately Wet |
|
||||||
|
| 6 | Very Wet |
|
||||||
|
| 7 | Extremely Wet |
|
||||||
|
|
||||||
|
### PaceMode
|
||||||
|
|
||||||
|
| 值 | 模式 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Single File Start |
|
||||||
|
| 1 | Double File Start |
|
||||||
|
| 2 | Single File Restart |
|
||||||
|
| 3 | Double File Restart |
|
||||||
|
| 4 | Not Pacing |
|
||||||
|
|
||||||
|
### CarLeftRight
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Off |
|
||||||
|
| 1 | Clear(两侧无车) |
|
||||||
|
| 2 | Car Left |
|
||||||
|
| 3 | Car Right |
|
||||||
|
| 4 | Cars on Both Sides |
|
||||||
|
| 5 | 2 Cars Left |
|
||||||
|
| 6 | 2 Cars Right |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、SDK 客户端 API(C 语言接口)
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 初始化 / 关闭
|
||||||
|
bool irsdk_startup();
|
||||||
|
void irsdk_shutdown();
|
||||||
|
|
||||||
|
// 连接检测
|
||||||
|
bool irsdk_isConnected();
|
||||||
|
|
||||||
|
// 获取头部
|
||||||
|
const irsdk_header* irsdk_getHeader();
|
||||||
|
|
||||||
|
// 等待新数据并复制到本地缓冲区(推荐方式)
|
||||||
|
bool irsdk_waitForDataReady(int timeOut, char *data);
|
||||||
|
|
||||||
|
// 获取会话 YAML 字符串
|
||||||
|
const char* irsdk_getSessionInfoStr();
|
||||||
|
int irsdk_getSessionInfoStrUpdate(); // 检测会话信息更新
|
||||||
|
|
||||||
|
// 获取变量头
|
||||||
|
const irsdk_varHeader* irsdk_getVarHeaderPtr();
|
||||||
|
const irsdk_varHeader* irsdk_getVarHeaderEntry(int index);
|
||||||
|
|
||||||
|
// 按名称查找变量
|
||||||
|
int irsdk_varNameToIndex(const char *name);
|
||||||
|
int irsdk_varNameToOffset(const char *name);
|
||||||
|
|
||||||
|
// 获取指定缓冲区的数据
|
||||||
|
const char* irsdk_getData(int index);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、Broadcast Messages(远程控制)
|
||||||
|
|
||||||
|
通过 Windows 消息 `IRSDK_BROADCASTMSG` 发送控制命令:
|
||||||
|
|
||||||
|
### 摄像机控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 切换摄像机:按位置
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchPos, carPosition, group, camera);
|
||||||
|
|
||||||
|
// 切换摄像机:按车号
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSwitchNum, driverNum, group, camera);
|
||||||
|
|
||||||
|
// 设置摄像机状态
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastCamSetState, irsdk_CameraState, 0, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 回放控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 设置播放速度
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySetPlaySpeed, speed, slowMotion, 0);
|
||||||
|
|
||||||
|
// 跳转位置
|
||||||
|
irsdk_broadcastMsg(irskd_BroadcastReplaySetPlayPosition,
|
||||||
|
irsdk_RpyPos_Begin, frameHigh, frameLow);
|
||||||
|
|
||||||
|
// 搜索事件
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySearch,
|
||||||
|
irsdk_RpySrch_NextLap, 0, 0);
|
||||||
|
|
||||||
|
// 搜索会话时间
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastReplaySearchSessionTime,
|
||||||
|
sessionNum, sessionTimeMS_high, sessionTimeMS_low);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 进站控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 清空所有勾选
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Clear, 0);
|
||||||
|
|
||||||
|
// 加油(0 = 使用默认值)
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_Fuel, addLiters);
|
||||||
|
|
||||||
|
// 换左前胎(指定胎压 kPa,0 = 默认)
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_LF, pressureKpa);
|
||||||
|
|
||||||
|
// 换胎配方
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_TC, compound);
|
||||||
|
|
||||||
|
// 快速维修
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastPitCommand, irsdk_PitCommand_FR, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 遥测录制控制
|
||||||
|
|
||||||
|
```c
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Start, 0, 0);
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Stop, 0, 0);
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastTelemCommand, irsdk_TelemCommand_Restart, 0, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 聊天宏
|
||||||
|
|
||||||
|
```c
|
||||||
|
irsdk_broadcastMsg(irsdk_BroadcastChatComand, irsdk_ChatCommand_Macro, macroNum, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、与 F1 24 / ACC 的对比
|
||||||
|
|
||||||
|
| 特性 | iRacing | F1 24 | ACC |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **协议** | 共享内存 | UDP | UDP + 共享内存 |
|
||||||
|
| **更新频率** | 60Hz(可 360Hz) | 最高 60Hz | 物理步进 |
|
||||||
|
| **多车数据** | ✅ CarIdx 数组(≤64 辆车) | ✅ 22 辆车 | ✅ Broadcasting |
|
||||||
|
| **回放数据** | ✅ .ibt 文件 | ❌ | ❌ |
|
||||||
|
| **远程控制** | ✅ Broadcast Msg | ❌ | ✅ Camera/Focus |
|
||||||
|
| **写入支持** | ✅ 进站/摄像机/遥测 | ❌ | ✅ Camera/HUD |
|
||||||
|
| **变量动态性** | 每车/每会话不同 | 固定结构 | 固定结构 |
|
||||||
|
| **子 Tick 精度** | ✅ `_ST` 后缀变量 | ❌ | ❌ |
|
||||||
|
| **运动平台** | ✅ 完整扭矩/加速度 | ✅ Motion Ex | ✅ Shared Memory |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、关键注意事项
|
||||||
|
|
||||||
|
1. **变量动态性**:iRacing 的变量列表不是固定的—不同车辆/会话中可能不同,必须先读取 `varHeader` 来确定可用的变量和偏移量
|
||||||
|
2. **只读**:所有遥测数据只读,不能通过 SDK 修改车辆参数(只能通过 Broadcast Message 控制进站选项)
|
||||||
|
3. **三缓冲**:务必在 `irsdk_waitForDataReady()` 返回后立即拷贝数据,不要直接引用共享内存
|
||||||
|
4. **Session Info 更新**:通过 `sessionInfoUpdate` 计数器检测 YAML 字符串是否变化
|
||||||
|
5. **.ibt 兼容**:磁盘遥测文件结构与 Live 相同,可用相同代码解析(多了 `irsdk_diskSubHeader`)
|
||||||
|
6. **启动时序**:先调用 `irsdk_startup()` → 等待 `irsdk_isConnected()` → 等待 `SessionState >= StateRacing`
|
||||||
|
7. **360Hz 模式**:部分车辆支持 360Hz 遥测,检查 `tickRate` 字段
|
||||||
+5
-1
@@ -17,6 +17,8 @@ games/user/my_game/
|
|||||||
"id": "my_game",
|
"id": "my_game",
|
||||||
"name": "我的游戏",
|
"name": "我的游戏",
|
||||||
"parser_type": "forza",
|
"parser_type": "forza",
|
||||||
|
"protocol": "udp",
|
||||||
|
"mode": "server",
|
||||||
"description": "自定义游戏遥测",
|
"description": "自定义游戏遥测",
|
||||||
"author": "你的名字",
|
"author": "你的名字",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -29,10 +31,12 @@ games/user/my_game/
|
|||||||
| `id` | string | 是 | 唯一标识,= 文件夹名 |
|
| `id` | string | 是 | 唯一标识,= 文件夹名 |
|
||||||
| `name` | string | 是 | 显示名称 |
|
| `name` | string | 是 | 显示名称 |
|
||||||
| `parser_type` | string | 是 | 解析器类型标签 |
|
| `parser_type` | string | 是 | 解析器类型标签 |
|
||||||
|
| `protocol` | string | 否 | 传输协议: `udp`(默认) / `tcp` |
|
||||||
|
| `mode` | string | 否 | TCP模式: `server`(框架监听) / `client`(插件自连) |
|
||||||
| `description` | string | 否 | 描述文字 |
|
| `description` | string | 否 | 描述文字 |
|
||||||
| `author` | string | 否 | 作者 |
|
| `author` | string | 否 | 作者 |
|
||||||
| `version` | string | 否 | 版本号 |
|
| `version` | string | 否 | 版本号 |
|
||||||
| `default_port` | int | 否 | 默认 UDP 端口 (默认 20777) |
|
| `default_port` | int | 否 | 默认端口 (UDP默认20777, TCP默认20778) |
|
||||||
|
|
||||||
## parser.py 规范
|
## parser.py 规范
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,25 +93,61 @@ 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
|
||||||
@@ -116,6 +155,9 @@ class TelemetryListener:
|
|||||||
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:
|
if self.forwarder._sock:
|
||||||
self.forwarder._sock.close()
|
self.forwarder._sock.close()
|
||||||
self.forwarder._sock = None
|
self.forwarder._sock = None
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -49,7 +49,7 @@ const Sidebar = {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ const Sidebar = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._gameLabelEl.textContent = '选择游戏';
|
this._gameLabelEl.textContent = Lang.t('selectGame');
|
||||||
},
|
},
|
||||||
|
|
||||||
async _selectGame(gameId) {
|
async _selectGame(gameId) {
|
||||||
|
|||||||
@@ -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,7 +28,7 @@ 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) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,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);
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ const PageDashboard = {
|
|||||||
async _loadThemes() {
|
async _loadThemes() {
|
||||||
const gridEl = document.getElementById('dash-theme-grid');
|
const gridEl = document.getElementById('dash-theme-grid');
|
||||||
if (!gridEl) return;
|
if (!gridEl) return;
|
||||||
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">' + Lang.t('loading') + '</div>';
|
||||||
|
|
||||||
if (this._marketMode) {
|
if (this._marketMode) {
|
||||||
await this._loadMarket(gridEl);
|
await this._loadMarket(gridEl);
|
||||||
@@ -61,9 +61,9 @@ const PageDashboard = {
|
|||||||
if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); }
|
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';
|
const sortBy = document.getElementById('dash-sort')?.value || 'name';
|
||||||
themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
|
themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
|
||||||
if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘'); return; }
|
if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml(Lang.t('noCompatibleDash')); return; }
|
||||||
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
||||||
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; }
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('loadFailed') + '</div>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
async _loadMarket(gridEl) {
|
async _loadMarket(gridEl) {
|
||||||
@@ -71,7 +71,7 @@ const PageDashboard = {
|
|||||||
const res = await fetch('/api/market/dashboards');
|
const res = await fetch('/api/market/dashboards');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (!items.length) { gridEl.innerHTML = this._emptyHtml('市场暂无仪表盘'); return; }
|
if (!items.length) { gridEl.innerHTML = this._emptyHtml(Lang.t('marketEmpty')); return; }
|
||||||
gridEl.innerHTML = items.map(d => {
|
gridEl.innerHTML = items.map(d => {
|
||||||
const icon = d.icon || '📦';
|
const icon = d.icon || '📦';
|
||||||
return `<div class="glass-card theme-card" data-market-id="${d.id}">
|
return `<div class="glass-card theme-card" data-market-id="${d.id}">
|
||||||
@@ -82,12 +82,12 @@ const PageDashboard = {
|
|||||||
<span>${d.category||''}</span><span>v${d.version||'1.0'}</span>
|
<span>${d.category||''}</span><span>v${d.version||'1.0'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card-meta" style="margin-top:4px">
|
<div class="theme-card-meta" style="margin-top:4px">
|
||||||
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次下载</span>
|
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${Lang.t('downloadCountShort').replace('%d', d.downloads||0)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-primary btn-market-install" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">安装</button>
|
<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>`;
|
</div></div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">市场加载失败</div>'; }
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('marketLoadFailed') + '</div>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
_isGameSupported(theme) {
|
_isGameSupported(theme) {
|
||||||
@@ -101,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>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,8 +149,8 @@ const PageDashboard = {
|
|||||||
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();
|
||||||
if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); }
|
if (data.ok) { Toast.show(Lang.t('importSuccess'), 'success'); this._loadThemes(); }
|
||||||
else Toast.show('导入失败', 'error');
|
else Toast.show(Lang.t('importFailed'), 'error');
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,14 +171,14 @@ const PageDashboard = {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`);
|
const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) { Toast.show('安装成功', 'success'); this._loadThemes(); }
|
if (data.ok) { Toast.show(Lang.t('installSuccess'), 'success'); this._loadThemes(); }
|
||||||
else Toast.show('安装失败', 'error');
|
else Toast.show(Lang.t('installFailed'), 'error');
|
||||||
} catch (e) { Toast.show('安装失败', 'error'); }
|
} catch (e) { Toast.show(Lang.t('installFailed'), 'error'); }
|
||||||
},
|
},
|
||||||
|
|
||||||
_showMarketUpload() {
|
_showMarketUpload() {
|
||||||
if (!this._marketToken) {
|
if (!this._marketToken) {
|
||||||
Toast.show('请先登录市场账户', 'info');
|
Toast.show(Lang.t('marketLoginPrompt'), 'info');
|
||||||
this._showMarketLogin();
|
this._showMarketLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -193,8 +193,8 @@ const PageDashboard = {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/market/upload', { method: 'POST', body: fd });
|
const res = await fetch('/api/market/upload', { method: 'POST', body: fd });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
Toast.show(data.error ? '上传失败: ' + data.error : '上传成功', data.error ? 'error' : 'success');
|
Toast.show(data.error ? Lang.t('uploadFailedMsg') + data.error : Lang.t('uploadSuccess'), data.error ? 'error' : 'success');
|
||||||
} catch (e) { Toast.show('上传失败', 'error'); }
|
} catch (e) { Toast.show(Lang.t('uploadFailed'), 'error'); }
|
||||||
}; input.click();
|
}; input.click();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -202,13 +202,13 @@ const PageDashboard = {
|
|||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'modal-overlay';
|
overlay.className = 'modal-overlay';
|
||||||
overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px">
|
overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px">
|
||||||
<h3>市场</h3>
|
<h3>${Lang.t('marketLogin')}</h3>
|
||||||
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email"></div>
|
<div class="form-group"><label>${Lang.t('email')}</label><input id="ml-email" type="email"></div>
|
||||||
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div>
|
<div class="form-group"><label>${Lang.t('password')}</label><input id="ml-pass" type="password"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary btn-sm" id="ml-logout" style="display:none;margin-right:auto">退出</button>
|
<button class="btn btn-secondary btn-sm" id="ml-logout" style="display:none;margin-right:auto">${Lang.t('logout')}</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="ml-register">注册</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">登录</button>
|
<button class="btn btn-primary btn-sm" id="ml-login">${Lang.t('login')}</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ const PageDashboard = {
|
|||||||
localStorage.removeItem('turbosu-market-token');
|
localStorage.removeItem('turbosu-market-token');
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
this._updateMarketUI();
|
this._updateMarketUI();
|
||||||
Toast.show('已退出', 'info');
|
Toast.show(Lang.t('loggedOut'), 'info');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,17 +234,17 @@ const PageDashboard = {
|
|||||||
if (data.token) {
|
if (data.token) {
|
||||||
this._marketToken = data.token;
|
this._marketToken = data.token;
|
||||||
localStorage.setItem('turbosu-market-token', data.token);
|
localStorage.setItem('turbosu-market-token', data.token);
|
||||||
Toast.show('登录成功', 'success');
|
Toast.show(Lang.t('loginSuccess'), 'success');
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
this._updateMarketUI();
|
this._updateMarketUI();
|
||||||
} else { Toast.show(data.message || '登录失败', 'error'); }
|
} else { Toast.show(data.message || Lang.t('loginFailed'), 'error'); }
|
||||||
});
|
});
|
||||||
overlay.querySelector('#ml-register').addEventListener('click', async () => {
|
overlay.querySelector('#ml-register').addEventListener('click', async () => {
|
||||||
const email = document.getElementById('ml-email').value;
|
const email = document.getElementById('ml-email').value;
|
||||||
const pass = document.getElementById('ml-pass').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 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();
|
const data = await res.json();
|
||||||
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件', data.error ? 'error' : 'success');
|
Toast.show(data.error ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), data.error ? 'error' : 'success');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -253,11 +253,11 @@ const PageDashboard = {
|
|||||||
const uploadBtn = document.getElementById('btn-market-upload');
|
const uploadBtn = document.getElementById('btn-market-upload');
|
||||||
if (!loginBtn || !uploadBtn) return;
|
if (!loginBtn || !uploadBtn) return;
|
||||||
if (this._marketToken) {
|
if (this._marketToken) {
|
||||||
loginBtn.textContent = '已登录';
|
loginBtn.textContent = Lang.t('loggedIn');
|
||||||
loginBtn.className = 'btn btn-sm btn-secondary';
|
loginBtn.className = 'btn btn-sm btn-secondary';
|
||||||
uploadBtn.style.display = '';
|
uploadBtn.style.display = '';
|
||||||
} else {
|
} else {
|
||||||
loginBtn.textContent = '登录';
|
loginBtn.textContent = Lang.t('login');
|
||||||
loginBtn.className = 'btn btn-sm btn-primary';
|
loginBtn.className = 'btn btn-sm btn-primary';
|
||||||
uploadBtn.style.display = 'none';
|
uploadBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -268,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');
|
||||||
});
|
});
|
||||||
@@ -279,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) {
|
||||||
@@ -287,7 +287,7 @@ 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 preview = theme.preview_ext ? `/dashboard/${theme.id}/preview.${theme.preview_ext}` : '';
|
||||||
const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : '';
|
const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : '';
|
||||||
@@ -309,7 +309,7 @@ const PageDashboard = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async _createNew() {
|
async _createNew() {
|
||||||
const name = prompt('仪表盘名称:', 'My Dashboard');
|
const name = prompt(Lang.t('dashboardNamePrompt'), 'My Dashboard');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
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 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>';
|
||||||
@@ -318,9 +318,9 @@ const PageDashboard = {
|
|||||||
body: JSON.stringify({name, category: 'custom', author: 'Local', config: {icon: '📊', aspect_ratio: 'auto', render_mode: 'contain'}, index_html: html})
|
body: JSON.stringify({name, category: 'custom', author: 'Local', config: {icon: '📊', aspect_ratio: 'auto', render_mode: 'contain'}, index_html: html})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.id) { Toast.show('创建成功', 'success'); this._loadThemes(); }
|
if (data.id) { Toast.show(Lang.t('createSuccess'), 'success'); this._loadThemes(); }
|
||||||
else Toast.show('创建失败', 'error');
|
else Toast.show(Lang.t('createFailed'), 'error');
|
||||||
} catch (e) { Toast.show('创建失败', 'error'); }
|
} catch (e) { Toast.show(Lang.t('createFailed'), 'error'); }
|
||||||
},
|
},
|
||||||
|
|
||||||
_template() {
|
_template() {
|
||||||
@@ -329,17 +329,17 @@ const PageDashboard = {
|
|||||||
<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;display:flex;flex-direction:column;gap:4px">
|
<div style="padding:8px 12px;margin-top:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
<button id="btn-new-dashboard" class="btn btn-sm btn-primary" style="width:100%;">+ 新建</button>
|
<button id="btn-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%;">导入 .tsd</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">
|
<div id="dash-search-box" style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
|
||||||
<input id="dash-search" type="text" placeholder="搜索仪表盘..." style="flex:1;max-width:300px">
|
<input id="dash-search" type="text" placeholder="${Lang.t('searchDashboards')}" style="flex:1;max-width:300px">
|
||||||
<select id="dash-sort" style="width:120px">
|
<select id="dash-sort" style="width:120px">
|
||||||
<option value="name">名称</option>
|
<option value="name">${Lang.t('sortByName')}</option>
|
||||||
<option value="category">分类</option>
|
<option value="category">${Lang.t('sortByCategory')}</option>
|
||||||
<option value="author">作者</option>
|
<option value="author">${Lang.t('sortByAuthor')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="dash-theme-grid" class="theme-grid"></div>
|
<div id="dash-theme-grid" class="theme-grid"></div>
|
||||||
|
|||||||
+11
-11
@@ -27,7 +27,7 @@ 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) => {
|
||||||
@@ -73,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() {
|
||||||
@@ -86,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;
|
||||||
@@ -101,24 +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">📄 日志</button>
|
<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>
|
||||||
|
|||||||
+19
-19
@@ -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-speed', '实时速度', '-- km/h', 'primary')}
|
${this._cardHtml('card-speed', Lang.t('liveSpeed'), '-- km/h', 'primary')}
|
||||||
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')}
|
${this._cardHtml('card-rpm', Lang.t('liveRpm'), '-- RPM', 'primary')}
|
||||||
${this._cardHtml('card-gear', '当前档位', '--', 'primary')}
|
${this._cardHtml('card-gear', Lang.t('liveGear'), '--', 'primary')}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-grid animated" style="animation-delay:0.15s">
|
<div class="status-grid animated" style="animation-delay:0.15s">
|
||||||
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')}
|
${this._cardHtml('card-server', Lang.t('serverStatus'), Lang.t('running'), 'success')}
|
||||||
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')}
|
${this._cardHtml('card-telemetry', Lang.t('telemetryStatus'), Lang.t('notStarted'), 'warning')}
|
||||||
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')}
|
${this._cardHtml('card-connections', Lang.t('wsConnections'), Lang.t('connectionsCount').replace('%d', 0), 'secondary')}
|
||||||
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')}
|
${this._cardHtml('card-packets', Lang.t('packetCount'), Lang.t('packetsCount').replace('%d', 0), 'secondary')}
|
||||||
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')}
|
${this._cardHtml('card-last-packet', Lang.t('lastPacket'), Lang.t('noData'), 'secondary')}
|
||||||
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')}
|
${this._cardHtml('card-game', Lang.t('currentGame'), Lang.t('notSelected'), 'info')}
|
||||||
</div>`;
|
</div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+87
-86
@@ -19,11 +19,11 @@ const PageMarket = {
|
|||||||
const grid = document.getElementById('market-grid');
|
const grid = document.getElementById('market-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
grid.style.display = 'grid';
|
grid.style.display = 'grid';
|
||||||
grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>';
|
grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">' + Lang.t('loading') + '</div>';
|
||||||
|
|
||||||
if (this._tab === 'mine') {
|
if (this._tab === 'mine') {
|
||||||
if (!this._marketToken) {
|
if (!this._marketToken) {
|
||||||
grid.innerHTML = this._emptyHtml('请先登录');
|
grid.innerHTML = this._emptyHtml(Lang.t('needLogin'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return this._loadMine(grid);
|
return this._loadMine(grid);
|
||||||
@@ -48,12 +48,12 @@ const PageMarket = {
|
|||||||
}
|
}
|
||||||
} catch(e) { console.error('load local:', e); }
|
} catch(e) { console.error('load local:', e); }
|
||||||
|
|
||||||
if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? '暂无场景' : '暂无仪表盘'); return; }
|
if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? Lang.t('noScenesMarket') : Lang.t('noDashboardsMarket')); return; }
|
||||||
grid.innerHTML = items.map(d => {
|
grid.innerHTML = items.map(d => {
|
||||||
const installed = d.manifest_id && localIds.includes(d.manifest_id);
|
const installed = d.manifest_id && localIds.includes(d.manifest_id);
|
||||||
return this._cardHtml(d, isScene, installed);
|
return this._cardHtml(d, isScene, installed);
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\'btn-refresh-market\').click();return false" style="color:var(--accent)">重试</a></div>'; }
|
} 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) {
|
async _loadMine(grid) {
|
||||||
@@ -62,27 +62,27 @@ const PageMarket = {
|
|||||||
const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken));
|
const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.dashboards?.length && !data.scenes?.length) {
|
if (!data.dashboards?.length && !data.scenes?.length) {
|
||||||
grid.innerHTML = this._emptyHtml('你还没有上传任何内容');
|
grid.innerHTML = this._emptyHtml(Lang.t('noUploads'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = '';
|
let html = '';
|
||||||
if (data.dashboards?.length) {
|
if (data.dashboards?.length) {
|
||||||
html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">我的仪表盘</h3>' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join('');
|
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) {
|
if (data.scenes?.length) {
|
||||||
html += '<h3 style="margin:20px 0 10px;font-size:16px;color:var(--text-secondary)">我的场景</h3>' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join('');
|
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;
|
grid.innerHTML = html;
|
||||||
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\"btn-refresh-market\").click();return false" style="color:var(--accent)">重试</a></div>'; }
|
} 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) {
|
_cardHtml(d, isScene, installed) {
|
||||||
const icon = d.preview ? '' : (isScene ? '🎬' : (d.icon || '📦'));
|
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 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 bgStyle = previewUrl ? `background-image:url(${previewUrl});background-size:cover;background-position:center` : '';
|
||||||
const label = installed ? '已安装' : (isScene ? '安装场景' : '安装');
|
const label = installed ? Lang.t('installed') : (isScene ? Lang.t('installScene') : Lang.t('install'));
|
||||||
const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-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%">卸载</button>` : '';
|
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">
|
return `<div class="glass-card theme-card">
|
||||||
<div class="theme-card-preview" style="${bgStyle}">${icon}</div>
|
<div class="theme-card-preview" style="${bgStyle}">${icon}</div>
|
||||||
<div class="theme-card-info">
|
<div class="theme-card-info">
|
||||||
@@ -91,7 +91,7 @@ const PageMarket = {
|
|||||||
<span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span>
|
<span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card-meta" style="margin-top:4px">
|
<div class="theme-card-meta" style="margin-top:4px">
|
||||||
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次</span>
|
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${Lang.t('downloadCountShort').replace('%d', d.downloads||0)}</span>
|
||||||
</div>
|
</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>
|
<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}
|
${uninstallBtn}
|
||||||
@@ -99,18 +99,18 @@ const PageMarket = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_myCardHtml(d, collection) {
|
_myCardHtml(d, collection) {
|
||||||
const pending = d.dashb_pending ? '<span class="badge" style="background:rgba(255,159,10,.15);color:#FF9F0A">待审核</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">已通过</span>';
|
const 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 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;
|
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">
|
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-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">
|
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:4px">
|
||||||
${d.author||''} · ${d.downloads||0} 次下载 · ${pending} <br>
|
${d.author||''} · ${Lang.t('downloadCount').replace('%d', d.downloads||0)} · ${pending} <br>
|
||||||
<span style="font-size:11px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</span>
|
<span style="font-size:11px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px">
|
<div style="display:flex;gap:6px">
|
||||||
<button class="btn btn-sm btn-secondary btn-my-update" data-id="${d.id}" data-coll="${collection}" data-file="${d.file||''}" data-preview="${d.preview||''}" data-type="${collection==='scenes'?'.tss':'.tsd'}">更新</button>
|
<button class="btn btn-sm btn-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}">删除</button>
|
<button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">${Lang.t('delete')}</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -126,17 +126,17 @@ const PageMarket = {
|
|||||||
if (e.target.closest('.btn-m-uninstall')) {
|
if (e.target.closest('.btn-m-uninstall')) {
|
||||||
const ubtn = e.target.closest('.btn-m-uninstall');
|
const ubtn = e.target.closest('.btn-m-uninstall');
|
||||||
const uname = ubtn.dataset.name||'';
|
const uname = ubtn.dataset.name||'';
|
||||||
if (!confirm('确定卸载 "' + uname + '"?')) return;
|
if (!confirm(Lang.t('confirmUninstall').replace('"$1"', uname))) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/dashboards?category=all');
|
const r = await fetch('/api/dashboards?category=all');
|
||||||
const list = (await r.json()) || [];
|
const list = (await r.json()) || [];
|
||||||
const dash = list.find(d => d.id === uname || d.name === uname);
|
const dash = list.find(d => d.id === uname || d.name === uname);
|
||||||
if (dash) {
|
if (dash) {
|
||||||
await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' });
|
await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' });
|
||||||
Toast.show('已卸载', 'success');
|
Toast.show(Lang.t('uninstallSuccess'), 'success');
|
||||||
this._loadItems();
|
this._loadItems();
|
||||||
} else { Toast.show('未找到本地安装', 'error'); }
|
} else { Toast.show(Lang.t('uninstallNotFound'), 'error'); }
|
||||||
} catch (e) { Toast.show('卸载失败', 'error'); }
|
} catch (e) { Toast.show(Lang.t('uninstallFailed'), 'error'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,31 +145,31 @@ const PageMarket = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (btnDelete) {
|
if (btnDelete) {
|
||||||
if (!confirm('确定删除?')) return;
|
if (!confirm(Lang.t('confirmDelete'))) return;
|
||||||
try {
|
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}) });
|
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) });
|
||||||
Toast.show('已删除', 'info');
|
Toast.show(Lang.t('deleted'), 'info');
|
||||||
this._loadItems();
|
this._loadItems();
|
||||||
} catch (e) { Toast.show('删除失败', 'error'); }
|
} catch (e) { Toast.show(Lang.t('deleteFailed'), 'error'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = (btnInstall || btnScene)?.dataset.id;
|
const id = (btnInstall || btnScene)?.dataset.id;
|
||||||
const file = (btnInstall || btnScene)?.dataset.file;
|
const file = (btnInstall || btnScene)?.dataset.file;
|
||||||
if (!id || !file) { Toast.show('安装失败:数据不完整', 'error'); return; }
|
if (!id || !file) { Toast.show(Lang.t('installDataIncomplete'), 'error'); return; }
|
||||||
const isScene = !!btnScene;
|
const isScene = !!btnScene;
|
||||||
const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`
|
const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`
|
||||||
: `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`;
|
: `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
Toast.show('安装成功!请在仪表盘页面查看', 'success', 4000);
|
Toast.show(Lang.t('installedHint'), 'success', 4000);
|
||||||
this._loadItems();
|
this._loadItems();
|
||||||
} else {
|
} else {
|
||||||
Toast.show('安装失败: ' + (data.error||'请求异常'), 'error');
|
Toast.show(Lang.t('installFailedMsg') + (data.error||''), 'error');
|
||||||
}
|
}
|
||||||
} catch (e) { Toast.show('安装失败:网络错误', 'error'); }
|
} catch (e) { Toast.show(Lang.t('installNetworkError'), 'error'); }
|
||||||
});
|
});
|
||||||
document.getElementById('tab-dashboards')?.addEventListener('click', () => {
|
document.getElementById('tab-dashboards')?.addEventListener('click', () => {
|
||||||
this._tab = 'dashboards';
|
this._tab = 'dashboards';
|
||||||
@@ -200,22 +200,22 @@ const PageMarket = {
|
|||||||
const statusEl = document.getElementById('m-status');
|
const statusEl = document.getElementById('m-status');
|
||||||
if (!loginBtn || !uploadBtn) return;
|
if (!loginBtn || !uploadBtn) return;
|
||||||
if (this._marketToken) {
|
if (this._marketToken) {
|
||||||
loginBtn.textContent = '已登录';
|
loginBtn.textContent = Lang.t('loggedIn');
|
||||||
loginBtn.className = 'btn btn-sm btn-secondary';
|
loginBtn.className = 'btn btn-sm btn-secondary';
|
||||||
uploadBtn.style.display = '';
|
uploadBtn.style.display = '';
|
||||||
if (statusEl) statusEl.textContent = ' 已登录';
|
if (statusEl) statusEl.textContent = ' ' + Lang.t('loggedIn');
|
||||||
} else {
|
} else {
|
||||||
loginBtn.textContent = '登录';
|
loginBtn.textContent = Lang.t('login');
|
||||||
loginBtn.className = 'btn btn-sm btn-primary';
|
loginBtn.className = 'btn btn-sm btn-primary';
|
||||||
uploadBtn.style.display = 'none';
|
uploadBtn.style.display = 'none';
|
||||||
if (statusEl) statusEl.textContent = ' 未登录';
|
if (statusEl) statusEl.textContent = ' ' + Lang.t('notLoggedIn');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_upload() {
|
_upload() {
|
||||||
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; }
|
if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; }
|
||||||
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
||||||
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">发布作品</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">选择要发布的仪表盘或场景</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle"><span class="switch-track"></span> 上传预览图</label></div><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择文件 (.tsd / .tss)</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">— 或从本地选择 —</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">加载中...</div></div>';
|
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);
|
document.body.appendChild(overlay);
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ const PageMarket = {
|
|||||||
const previewDrop = document.createElement('div');
|
const previewDrop = document.createElement('div');
|
||||||
previewDrop.id = 'pub-preview-drop';
|
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.style.cssText = 'display:none;border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin-top:8px;transition:all .2s';
|
||||||
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)';
|
previewDrop.textContent = Lang.t('dropPreview');
|
||||||
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn'));
|
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn'));
|
||||||
|
|
||||||
function pickPreview() {
|
function pickPreview() {
|
||||||
@@ -231,10 +231,10 @@ const PageMarket = {
|
|||||||
inp.onchange = function() {
|
inp.onchange = function() {
|
||||||
const f = this.files[0];
|
const f = this.files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
|
||||||
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
|
||||||
previewFile = f;
|
previewFile = f;
|
||||||
previewDrop.textContent = '✓ ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
||||||
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
};
|
};
|
||||||
document.body.appendChild(inp); inp.click();
|
document.body.appendChild(inp); inp.click();
|
||||||
@@ -246,10 +246,10 @@ const PageMarket = {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const f = e.dataTransfer.files[0];
|
const f = e.dataTransfer.files[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
|
||||||
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
|
||||||
previewFile = f;
|
previewFile = f;
|
||||||
previewDrop.textContent = '✓ ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
|
||||||
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
});
|
});
|
||||||
previewDrop.addEventListener('click', pickPreview);
|
previewDrop.addEventListener('click', pickPreview);
|
||||||
@@ -260,7 +260,7 @@ const PageMarket = {
|
|||||||
} else {
|
} else {
|
||||||
previewFile = null;
|
previewFile = null;
|
||||||
previewDrop.style.display = 'none';
|
previewDrop.style.display = 'none';
|
||||||
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)';
|
previewDrop.textContent = Lang.t('dropPreview');
|
||||||
previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)';
|
previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -274,7 +274,7 @@ const PageMarket = {
|
|||||||
if (previewFile) fd.append('preview', previewFile);
|
if (previewFile) fd.append('preview', previewFile);
|
||||||
const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd });
|
const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
Toast.show(data.error ? '失败: '+data.error : '发布成功', data.error ? 'error' : 'success');
|
Toast.show(data.error ? Lang.t('publishFailed') + ': '+data.error : Lang.t('publishSuccess'), data.error ? 'error' : 'success');
|
||||||
if (!data.error) { overlay.remove(); this._loadItems(); }
|
if (!data.error) { overlay.remove(); this._loadItems(); }
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
@@ -292,27 +292,27 @@ const PageMarket = {
|
|||||||
const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.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 = '';
|
let h = '';
|
||||||
if (upD.length) {
|
if (upD.length) {
|
||||||
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地仪表盘 ('+upD.length+')</div>';
|
h += '<div class="pub-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+'">发布</button></div>').join('') + '</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) {
|
if (upS.length) {
|
||||||
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin:10px 0 4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地场景 ('+upS.length+')</div>';
|
h += '<div class="pub-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+'">发布</button></div>').join('') + '</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">没有可发布的内容</p>';
|
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 () => {
|
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 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();
|
const d = await r.json();
|
||||||
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
|
Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success');
|
||||||
if(!d.error){overlay.remove();this._loadItems();}
|
if(!d.error){overlay.remove();this._loadItems();}
|
||||||
}));
|
}));
|
||||||
list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => {
|
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 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();
|
const d = await r.json();
|
||||||
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success');
|
Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success');
|
||||||
if(!d.error){overlay.remove();this._loadItems();}
|
if(!d.error){overlay.remove();this._loadItems();}
|
||||||
}));
|
}));
|
||||||
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">加载失败</p>'; }
|
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">' + Lang.t('loadFailed') + '</p>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
_showLogin() {
|
_showLogin() {
|
||||||
@@ -321,16 +321,16 @@ const PageMarket = {
|
|||||||
const isLogin = !this._marketToken;
|
const isLogin = !this._marketToken;
|
||||||
overlay.innerHTML = `<div class="modal" style="min-width:380px;max-width:420px;padding:24px">
|
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)">
|
<div style="display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border-subtle)">
|
||||||
<button id="ml-tab-login" class="btn btn-sm ${isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${isLogin?'var(--accent)':'transparent'};color:${isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">登录</button>
|
<button id="ml-tab-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">注册</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>
|
||||||
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email" placeholder="your@email.com"></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>用户名</label><input id="ml-name" type="text" placeholder="必填"></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>密码</label><input id="ml-pass" type="password"></div>
|
<div class="form-group"><label>${Lang.t('password')}</label><input id="ml-pass" type="password"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-sm" id="ml-logout" style="display:${this._marketToken?'':'none'};margin-right:auto;background:var(--bg2);color:var(--text2)">退出</button>
|
<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>
|
<span style="flex:1"></span>
|
||||||
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?'登录':'注册'}</button>
|
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?Lang.t('login'):Lang.t('register')}</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ const PageMarket = {
|
|||||||
localStorage.removeItem('turbosu-market-token');
|
localStorage.removeItem('turbosu-market-token');
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
this._updateUI();
|
this._updateUI();
|
||||||
Toast.show('已退出', 'info');
|
Toast.show(Lang.t('loggedOut'), 'info');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ const PageMarket = {
|
|||||||
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`;
|
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`;
|
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' : '';
|
nameGroup.style.display = isL ? 'none' : '';
|
||||||
submitBtn.textContent = isL ? '登录' : '注册';
|
submitBtn.textContent = isL ? Lang.t('login') : Lang.t('register');
|
||||||
}
|
}
|
||||||
|
|
||||||
tabLogin.addEventListener('click', () => setTab('login'));
|
tabLogin.addEventListener('click', () => setTab('login'));
|
||||||
@@ -367,22 +367,22 @@ const PageMarket = {
|
|||||||
const doAuth = async () => {
|
const doAuth = async () => {
|
||||||
const email = document.getElementById('ml-email').value;
|
const email = document.getElementById('ml-email').value;
|
||||||
const pass = document.getElementById('ml-pass').value;
|
const pass = document.getElementById('ml-pass').value;
|
||||||
if (!email || !pass) { Toast.show('请输入邮箱和密码', 'error'); return; }
|
if (!email || !pass) { Toast.show(Lang.t('enterEmailPassword'), 'error'); return; }
|
||||||
if (mode === 'login') {
|
if (mode === 'login') {
|
||||||
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) });
|
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();
|
const data = await res.json();
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
this._marketToken = data.token;
|
this._marketToken = data.token;
|
||||||
localStorage.setItem('turbosu-market-token', data.token);
|
localStorage.setItem('turbosu-market-token', data.token);
|
||||||
Toast.show('登录成功', 'success');
|
Toast.show(Lang.t('loginSuccess'), 'success');
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
this._updateUI();
|
this._updateUI();
|
||||||
} else { Toast.show(data.message || '登录失败', 'error'); }
|
} else { Toast.show(data.message || Lang.t('loginFailed'), 'error'); }
|
||||||
} else {
|
} else {
|
||||||
const name = document.getElementById('ml-name')?.value || '';
|
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 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();
|
const data = await res.json();
|
||||||
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件(点击关闭)', data.error ? 'error' : 'success', data.error ? 3000 : 0);
|
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();
|
if (!data.error) overlay.remove();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -392,30 +392,31 @@ const PageMarket = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_emptyHtml(msg) {
|
_emptyHtml(msg) {
|
||||||
return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||'暂无'}</h4></div>`;
|
return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||Lang.t('marketNoItems')}</h4></div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
_showUpdateModal(ds) {
|
_showUpdateModal(ds) {
|
||||||
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; }
|
if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; }
|
||||||
const isScene = ds.coll === 'scenes';
|
const isScene = ds.coll === 'scenes';
|
||||||
|
const typeLabel = isScene ? Lang.t('scene') : Lang.t('dashboard');
|
||||||
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
|
||||||
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">更新 '+(isScene?'场景':'仪表盘')+'</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">重新上传包文件或更换预览图</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle" checked><span class="switch-track"></span> 更换预览图</label></div><button id="pub-update-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择新文件 ('+(isScene?'.tss':'.tsd')+')</button><div style="font-size:13px;color:var(--text-tertiary);margin-top:10px;text-align:center">注:仅更新预览图可不选文件</div></div>';
|
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);
|
document.body.appendChild(overlay);
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
|
||||||
let updateFile = null, previewFile = null;
|
let updateFile = null, previewFile = null;
|
||||||
const previewDrop = document.createElement('div');
|
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.style.cssText = 'border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin:8px 0;transition:all .2s';
|
||||||
previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)';
|
previewDrop.textContent = Lang.t('dropPreview');
|
||||||
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn'));
|
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn'));
|
||||||
|
|
||||||
function pickPreview() {
|
function pickPreview() {
|
||||||
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
|
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
|
||||||
inp.onchange = function() {
|
inp.onchange = function() {
|
||||||
const f = this.files[0]; if (!f) return;
|
const f = this.files[0]; if (!f) return;
|
||||||
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
|
||||||
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
|
||||||
previewFile = f; previewDrop.textContent = '✓ '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
|
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.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
};
|
};
|
||||||
document.body.appendChild(inp); inp.click();
|
document.body.appendChild(inp); inp.click();
|
||||||
@@ -424,27 +425,27 @@ const PageMarket = {
|
|||||||
['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; }));
|
['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; }));
|
||||||
previewDrop.addEventListener('drop', function(e) {
|
previewDrop.addEventListener('drop', function(e) {
|
||||||
e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return;
|
e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return;
|
||||||
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; }
|
if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
|
||||||
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; }
|
if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
|
||||||
previewFile = f; previewDrop.textContent = '✓ '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
|
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.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
|
||||||
});
|
});
|
||||||
previewDrop.addEventListener('click', pickPreview);
|
previewDrop.addEventListener('click', pickPreview);
|
||||||
|
|
||||||
overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() {
|
overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() {
|
||||||
previewDrop.style.display = this.checked ? '' : 'none';
|
previewDrop.style.display = this.checked ? '' : 'none';
|
||||||
if (!this.checked) { previewFile = null; previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)'; previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; }
|
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', () => {
|
overlay.querySelector('#pub-update-btn').addEventListener('click', () => {
|
||||||
const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd';
|
const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd';
|
||||||
input.onchange = function(e) { updateFile = e.target.files[0]; if (updateFile) { const btn = overlay.querySelector('#pub-update-btn'); btn.textContent = '✓ '+updateFile.name; btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; } };
|
input.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();
|
input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
overlay.querySelector('#pub-update-btn').insertAdjacentHTML('afterend', '<button id="pub-do-update" class="btn btn-primary btn-sm" style="width:100%;margin-top:10px">确认更新</button>');
|
overlay.querySelector('#pub-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 () => {
|
overlay.querySelector('#pub-do-update').addEventListener('click', async () => {
|
||||||
if (!updateFile && !previewFile) { Toast.show('请选择文件或预览图', 'error'); return; }
|
if (!updateFile && !previewFile) { Toast.show(Lang.t('selectFileOrPreview'), 'error'); return; }
|
||||||
const fd = new FormData(); fd.append('token', this._marketToken);
|
const fd = new FormData(); fd.append('token', this._marketToken);
|
||||||
if (updateFile) fd.append('file', updateFile);
|
if (updateFile) fd.append('file', updateFile);
|
||||||
if (previewFile) fd.append('preview', previewFile);
|
if (previewFile) fd.append('preview', previewFile);
|
||||||
@@ -465,7 +466,7 @@ const PageMarket = {
|
|||||||
// Delete old record
|
// 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}) });
|
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: ds.id, collection: ds.coll, token: this._marketToken}) });
|
||||||
}
|
}
|
||||||
Toast.show(data.error ? '更新失败: '+data.error : '更新成功', data.error ? 'error' : 'success');
|
Toast.show(data.error ? Lang.t('updateFailed') + data.error : Lang.t('updated'), data.error ? 'error' : 'success');
|
||||||
if (!data.error) { overlay.remove(); this._loadItems(); }
|
if (!data.error) { overlay.remove(); this._loadItems(); }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -474,20 +475,20 @@ const PageMarket = {
|
|||||||
return `<div class="animated">
|
return `<div class="animated">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:24px;font-weight:700">仪表盘市场</h2>
|
<h2 style="font-size:24px;font-weight:700">${Lang.t('marketPage')}</h2>
|
||||||
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">浏览并安装社区分享的仪表盘和场景 <span id="m-status" style="color:var(--text-tertiary)"> 未登录</span></p>
|
<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>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">发布你的作品</button>
|
<button id="btn-m-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">登录</button>
|
<button id="btn-m-login" class="btn btn-sm btn-primary">${Lang.t('login')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle);align-items:flex-end">
|
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle);align-items:flex-end">
|
||||||
<button id="tab-dashboards" class="btn btn-sm active" style="border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px">仪表盘</button>
|
<button id="tab-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">场景</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">我的</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>
|
<span style="flex:1"></span>
|
||||||
<button id="btn-refresh-market" class="icon-btn" style="width:auto;padding:0 8px;margin-bottom:4px;font-size:12px" title="刷新">🔄</button>
|
<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>
|
||||||
<div id="market-grid" class="theme-grid"></div>
|
<div id="market-grid" class="theme-grid"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+10
-10
@@ -15,10 +15,10 @@ const PageRecord = {
|
|||||||
const list = await res.json();
|
const list = await res.json();
|
||||||
const el = document.getElementById('rec-file-list');
|
const el = document.getElementById('rec-file-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:13px;padding:20px">暂无录制文件</p>'; 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 => {
|
el.innerHTML = list.map(f => {
|
||||||
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
||||||
return '<div class="glass-card" style="padding:10px 14px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap"><span style="font-size:12px;flex:1;min-width:150px">' + f.name + '</span><span style="font-size:11px;color:var(--text-tertiary)">' + size + '</span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button><a class="btn btn-sm btn-secondary" href="/api/recording/' + f.name + '/export" download>导出</a><button class="btn btn-sm btn-danger btn-del-rec" data-file="' + f.name + '">删除</button></div>';
|
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('');
|
}).join('');
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
},
|
},
|
||||||
@@ -42,14 +42,14 @@ const PageRecord = {
|
|||||||
const file = e.target.files[0]; if (!file) return;
|
const file = e.target.files[0]; if (!file) return;
|
||||||
const fd = new FormData(); fd.append('file', file);
|
const fd = new FormData(); fd.append('file', file);
|
||||||
await fetch('/api/recording/import', { method: 'POST', body: fd });
|
await fetch('/api/recording/import', { method: 'POST', body: fd });
|
||||||
Toast.show('导入成功', 'success'); this._loadRecordings();
|
Toast.show(Lang.t('importSuccess'), 'success'); this._loadRecordings();
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
document.getElementById('rec-file-list')?.addEventListener('click', async (e) => {
|
document.getElementById('rec-file-list')?.addEventListener('click', async (e) => {
|
||||||
const playBtn = e.target.closest('.btn-play-rec');
|
const playBtn = e.target.closest('.btn-play-rec');
|
||||||
const delBtn = e.target.closest('.btn-del-rec');
|
const delBtn = e.target.closest('.btn-del-rec');
|
||||||
if (playBtn) this._playRecording(playBtn.dataset.file);
|
if (playBtn) this._playRecording(playBtn.dataset.file);
|
||||||
if (delBtn && confirm('删除?')) {
|
if (delBtn && confirm(Lang.t('confirmDelete'))) {
|
||||||
await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' });
|
await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' });
|
||||||
this._loadRecordings();
|
this._loadRecordings();
|
||||||
}
|
}
|
||||||
@@ -63,24 +63,24 @@ const PageRecord = {
|
|||||||
_updateRecUI(recording) {
|
_updateRecUI(recording) {
|
||||||
document.getElementById('btn-rec-start').style.display = recording ? 'none' : '';
|
document.getElementById('btn-rec-start').style.display = recording ? 'none' : '';
|
||||||
document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none';
|
document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none';
|
||||||
document.getElementById('rec-status').textContent = recording ? '录制中...' : '就绪';
|
document.getElementById('rec-status').textContent = recording ? Lang.t('recording') : Lang.t('ready');
|
||||||
},
|
},
|
||||||
|
|
||||||
async _playRecording(filename) {
|
async _playRecording(filename) {
|
||||||
document.getElementById('playback-file').textContent = filename;
|
document.getElementById('playback-file').textContent = filename;
|
||||||
document.getElementById('playback-bar').style.display = 'flex';
|
document.getElementById('playback-bar').style.display = 'flex';
|
||||||
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
|
||||||
Toast.show('回放已开始,打开仪表盘查看数据', 'info');
|
Toast.show(Lang.t('playbackStartedHint'), 'info');
|
||||||
},
|
},
|
||||||
|
|
||||||
_template() {
|
_template() {
|
||||||
return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:8px">录制与回放</h2><p style="color:var(--text-secondary);margin-bottom:20px">录制原始 UDP 流到 .tsr 文件,回放调试仪表盘</p>' +
|
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">录制控制</h3><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><button id="btn-rec-start" class="btn btn-primary">开始录制</button><button id="btn-rec-stop" class="btn btn-danger" style="display:none">停止录制</button><span id="rec-status" style="font-size:13px;color:var(--text-secondary)">就绪</span><span style="flex:1"></span><label class="switch-label"><input type="checkbox" id="rec-auto-mode"><span class="switch-track"></span> 自动检测</label></div></div>' +
|
'<div 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">正在回放: <strong id="playback-file">-</strong></span><span style="flex:1"></span><span style="font-size:12px;color:var(--text-tertiary)">打开仪表盘页面即可看到数据</span><button id="btn-playback-stop" class="btn btn-sm btn-danger">停止</button></div>' +
|
'<div 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">录制文件</h3><div style="display:flex;gap:8px;margin-bottom:12px"><button id="btn-import-rec" class="btn btn-sm btn-secondary">导入 .tsr</button></div><div id="rec-file-list"></div></div>' +
|
'<div 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>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-23
@@ -38,11 +38,11 @@ const PageScene = {
|
|||||||
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 = `<div class="empty-state" style="grid-column:1/-1"><h4>暂无场景</h4><p>点击"新建场景"创建你的场景</p></div>`;
|
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>${Lang.t('noScenes')}</h4><p>${Lang.t('createFirstScene')}</p></div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
||||||
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>' + Lang.t('loadFailed') + '</p></div>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
async _openEditor(sceneId) {
|
async _openEditor(sceneId) {
|
||||||
@@ -79,7 +79,7 @@ const PageScene = {
|
|||||||
<span>${d.icon||'📊'}</span>
|
<span>${d.icon||'📊'}</span>
|
||||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
|
||||||
</div>`
|
</div>`
|
||||||
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">无兼容仪表盘</p>';
|
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">' + Lang.t('noCompatibleDashForScene') + '</p>';
|
||||||
|
|
||||||
list.querySelectorAll('.editor-dash-item').forEach(el => {
|
list.querySelectorAll('.editor-dash-item').forEach(el => {
|
||||||
el.addEventListener('dragstart', (e) => {
|
el.addEventListener('dragstart', (e) => {
|
||||||
@@ -174,9 +174,9 @@ const PageScene = {
|
|||||||
const created = await API.createScene(data);
|
const created = await API.createScene(data);
|
||||||
this._editorSceneId = created.id;
|
this._editorSceneId = created.id;
|
||||||
}
|
}
|
||||||
Toast.show('场景保存成功', 'success');
|
Toast.show(Lang.t('sceneSaveSuccess'), 'success');
|
||||||
this._showSceneList();
|
this._showSceneList();
|
||||||
} catch (e) { Toast.show('保存失败: ' + e.message, 'error'); }
|
} catch (e) { Toast.show(Lang.t('saveFailed') + e.message, 'error'); }
|
||||||
},
|
},
|
||||||
|
|
||||||
_bindEvents() {
|
_bindEvents() {
|
||||||
@@ -188,8 +188,8 @@ const PageScene = {
|
|||||||
const fd = new FormData(); fd.append('file', file);
|
const fd = new FormData(); fd.append('file', file);
|
||||||
const res = await fetch('/api/scenes/import', { method: 'POST', body: fd });
|
const res = await fetch('/api/scenes/import', { method: 'POST', body: fd });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) { Toast.show('场景导入成功', 'success'); this._showSceneList(); }
|
if (data.ok) { Toast.show(Lang.t('sceneImportSuccess'), 'success'); this._showSceneList(); }
|
||||||
else Toast.show('导入失败', 'error');
|
else Toast.show(Lang.t('importFailed'), 'error');
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ const PageScene = {
|
|||||||
const action = card.dataset.action, sceneId = card.dataset.sceneId;
|
const action = card.dataset.action, sceneId = card.dataset.sceneId;
|
||||||
if (action === 'render') this._renderScene(sceneId);
|
if (action === 'render') this._renderScene(sceneId);
|
||||||
else if (action === 'edit') this._openEditor(sceneId);
|
else if (action === 'edit') this._openEditor(sceneId);
|
||||||
else if (action === 'delete') { if (confirm('确定删除?')) { API.deleteScene(sceneId); this._showSceneList(); } }
|
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(); }
|
else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); }
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -210,7 +210,7 @@ const PageScene = {
|
|||||||
_renderScene(sceneId) {
|
_renderScene(sceneId) {
|
||||||
const url = `${location.origin}/scene/${sceneId}`;
|
const url = `${location.origin}/scene/${sceneId}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {});
|
navigator.clipboard.writeText(url).then(() => Toast.show(Lang.t('linkCopiedShort'), 'success')).catch(() => {});
|
||||||
},
|
},
|
||||||
|
|
||||||
_sceneCardHtml(scene) {
|
_sceneCardHtml(scene) {
|
||||||
@@ -219,14 +219,14 @@ const PageScene = {
|
|||||||
return `<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">
|
||||||
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
|
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
|
||||||
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
|
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
|
||||||
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || '未关联游戏'}</div>
|
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || Lang.t('noGame')}</div>
|
||||||
<div class="scene-card-meta"><span>${canvasCount} 画布</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
|
<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>
|
||||||
<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 class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
|
||||||
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
|
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
|
||||||
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button>
|
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">${Lang.t('render')}</button>
|
||||||
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button>
|
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">${Lang.t('edit')}</button>
|
||||||
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button>
|
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">${Lang.t('export')}</button>
|
||||||
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
|
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">${Lang.t('delete')}</button>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -234,12 +234,12 @@ const PageScene = {
|
|||||||
return `
|
return `
|
||||||
<div class="scene-header animated" id="scene-list-container">
|
<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">${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}</p>
|
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? Lang.t('currentGamePrefix') + this._currentGameName : Lang.t('noGameSelected')}</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>
|
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>${Lang.t('newScene')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="scene-grid" class="scene-grid animated"></div>
|
<div id="scene-grid" class="scene-grid animated"></div>
|
||||||
@@ -247,14 +247,14 @@ const PageScene = {
|
|||||||
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
|
<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;justify-content:space-between;margin-bottom:12px">
|
||||||
<div style="display:flex;align-items:center;gap:12px">
|
<div style="display:flex;align-items:center;gap:12px">
|
||||||
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">← 返回</button>
|
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">${Lang.t('backToList')}</button>
|
||||||
<input id="edit-scene-name" placeholder="场景名称" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
|
<input id="edit-scene-name" placeholder="${Lang.t('sceneName')}" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
|
||||||
</div>
|
</div>
|
||||||
<button id="btn-save-scene" class="btn btn-primary btn-sm">保存场景</button>
|
<button id="btn-save-scene" class="btn btn-primary btn-sm">${Lang.t('saveScene')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
<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">
|
<div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px">
|
||||||
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">仪表盘列表 (拖到画布)</h4>
|
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">${Lang.t('dashListDrag')}</h4>
|
||||||
<div id="editor-dash-list"></div>
|
<div id="editor-dash-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
|
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
|
||||||
|
|||||||
+48
-43
@@ -31,18 +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) => {
|
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
|
||||||
await API.updateConfig({ use_unified_port: e.target.checked });
|
await API.updateConfig({ use_unified_port: e.target.checked });
|
||||||
this._toggleGamePorts(e.target.checked);
|
this._toggleGamePorts(e.target.checked);
|
||||||
Toast.show('已更新', 'info');
|
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());
|
||||||
@@ -61,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) => {
|
||||||
@@ -78,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(); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,18 +107,18 @@ const PageSettings = {
|
|||||||
const fd = new FormData(); fd.append('file', file);
|
const fd = new FormData(); fd.append('file', file);
|
||||||
const res = await fetch('/api/backup/import', { method: 'POST', body: fd });
|
const res = await fetch('/api/backup/import', { method: 'POST', body: fd });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
Toast.show(data.ok ? data.message || '恢复成功' : '恢复失败: ' + data.message, data.ok ? 'success' : 'error');
|
Toast.show(data.ok ? data.message || Lang.t('restoreSuccess') : Lang.t('restoreFailed') + data.message, data.ok ? 'success' : 'error');
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-restart-server')?.addEventListener('click', async () => {
|
document.getElementById('btn-restart-server')?.addEventListener('click', async () => {
|
||||||
if (!confirm('确定重启 TurboSu 服务?')) return;
|
if (!confirm(Lang.t('confirmRestart'))) return;
|
||||||
try {
|
try {
|
||||||
await fetch('/api/restart', { method: 'POST' });
|
await fetch('/api/restart', { method: 'POST' });
|
||||||
Toast.show('服务正在重启,3秒后自动刷新...', 'info');
|
Toast.show(Lang.t('restarting'), 'info');
|
||||||
setTimeout(() => location.reload(), 3000);
|
setTimeout(() => location.reload(), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.show('重启请求已发送,请手动刷新', 'info');
|
Toast.show(Lang.t('restartRequestSent'), 'info');
|
||||||
setTimeout(() => location.reload(), 3000);
|
setTimeout(() => location.reload(), 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -138,13 +138,13 @@ const PageSettings = {
|
|||||||
await fetch('/api/record/start', { method: 'POST' });
|
await fetch('/api/record/start', { method: 'POST' });
|
||||||
document.getElementById('btn-rec-start').style.display = 'none';
|
document.getElementById('btn-rec-start').style.display = 'none';
|
||||||
document.getElementById('btn-rec-stop').style.display = '';
|
document.getElementById('btn-rec-stop').style.display = '';
|
||||||
document.getElementById('rec-status').textContent = '录制中...';
|
document.getElementById('rec-status').textContent = Lang.t('recording');
|
||||||
});
|
});
|
||||||
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
|
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
|
||||||
await fetch('/api/record/stop', { method: 'POST' });
|
await fetch('/api/record/stop', { method: 'POST' });
|
||||||
document.getElementById('btn-rec-start').style.display = '';
|
document.getElementById('btn-rec-start').style.display = '';
|
||||||
document.getElementById('btn-rec-stop').style.display = 'none';
|
document.getElementById('btn-rec-stop').style.display = 'none';
|
||||||
document.getElementById('rec-status').textContent = '已停止';
|
document.getElementById('rec-status').textContent = Lang.t('stopped');
|
||||||
this._loadRecordings();
|
this._loadRecordings();
|
||||||
});
|
});
|
||||||
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
|
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
|
||||||
@@ -157,10 +157,10 @@ const PageSettings = {
|
|||||||
await API.updateConfig({ ui_test_mode: on });
|
await API.updateConfig({ ui_test_mode: on });
|
||||||
if (on) {
|
if (on) {
|
||||||
await fetch('/api/test-mode/start', { method: 'POST' });
|
await fetch('/api/test-mode/start', { method: 'POST' });
|
||||||
Toast.show('测试模式已开启', 'success');
|
Toast.show(Lang.t('testModeOn'), 'success');
|
||||||
} else {
|
} else {
|
||||||
await fetch('/api/test-mode/stop', { method: 'POST' });
|
await fetch('/api/test-mode/stop', { method: 'POST' });
|
||||||
Toast.show('测试模式已关闭', 'info');
|
Toast.show(Lang.t('testModeOff'), 'info');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ const PageSettings = {
|
|||||||
if (id && port > 0) gamePorts[id] = port;
|
if (id && port > 0) gamePorts[id] = port;
|
||||||
});
|
});
|
||||||
await API.updateConfig({ game_ports: gamePorts });
|
await API.updateConfig({ game_ports: gamePorts });
|
||||||
Toast.show('端口已保存', 'success');
|
Toast.show(Lang.t('portsSaved'), 'success');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -182,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 class="switch-label"><input type="checkbox" class="forward-toggle" checked><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button>';
|
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);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ 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() {
|
async _loadRecordings() {
|
||||||
@@ -205,15 +205,15 @@ const PageSettings = {
|
|||||||
const list = await res.json();
|
const list = await res.json();
|
||||||
const el = document.getElementById('recording-list');
|
const el = document.getElementById('recording-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:12px">暂无录制文件</p>'; 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 => {
|
el.innerHTML = list.map(f => {
|
||||||
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
|
||||||
return '<div class="glass-card" style="padding:8px 12px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">' + f.name + ' <span style="color:var(--text-tertiary)">' + size + '</span></span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button></div>';
|
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('');
|
}).join('');
|
||||||
el.querySelectorAll('.btn-play-rec').forEach(btn => {
|
el.querySelectorAll('.btn-play-rec').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) });
|
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) });
|
||||||
Toast.show('回放已开始', 'success');
|
Toast.show(Lang.t('playbackStarted'), 'success');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
@@ -221,50 +221,55 @@ const PageSettings = {
|
|||||||
|
|
||||||
_gamePortListHtml(games, cfg) {
|
_gamePortListHtml(games, cfg) {
|
||||||
const gamePorts = cfg.game_ports || {};
|
const gamePorts = cfg.game_ports || {};
|
||||||
return games.map(g => '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:140px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || 20777) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">默认: ' + (g.default_port || 20777) + '</span></div>').join('');
|
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 class="switch-label"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button></div>').join('');
|
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) {
|
||||||
const sections = [
|
const sections = [
|
||||||
{ id: 'sec-forward', label: '数据转发' },
|
{ id: 'sec-forward', label: Lang.t('dataForwarding') },
|
||||||
{ id: 'sec-connection', label: '连接设置' },
|
{ id: 'sec-connection', label: Lang.t('connectionSettings') },
|
||||||
{ id: 'sec-dashboards', label: '仪表盘管理' },
|
{ id: 'sec-dashboards', label: Lang.t('dashManagement') },
|
||||||
{ id: 'sec-plugins', label: '游戏插件' },
|
{ id: 'sec-plugins', label: Lang.t('pluginManagement') },
|
||||||
{ id: 'sec-backup', label: '数据备份' },
|
{ id: 'sec-backup', label: Lang.t('dataBackup') },
|
||||||
{ id: 'sec-dev', label: '开发工具' },
|
{ id: 'sec-dev', label: Lang.t('devTools') },
|
||||||
{ id: 'sec-system', label: '系统' },
|
{ id: 'sec-system', label: Lang.t('systemSection') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return '<div class="settings-layout"><div class="settings-nav">' +
|
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('') +
|
sections.map(s => '<a class="settings-nav-item" data-target="' + s.id + '">' + s.label + '</a>').join('') +
|
||||||
'</div><div class="settings-main"><div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:24px">设置</h2>' +
|
'</div><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" id="sec-forward"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' +
|
'<div class="settings-section" 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" id="sec-connection"><h3>连接设置</h3><div class="settings-row"><div><div class="settings-label">遥测监听端口</div><div class="settings-desc">全局统一端口</div></div><div class="settings-control"><input type="number" id="settings-telemetry-port" value="' + (cfg.telemetry_port||20777) + '" min="1024" max="65535"></div></div><div class="settings-row"><div><div class="settings-label">使用游戏独立端口</div><div class="settings-desc">关闭后切游戏自动切换到各游戏的默认端口</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '><span class="switch-track"></span></label></div></div><div id="game-ports-section" style="margin-top:8px;padding:12px;background:var(--bg-tertiary);border-radius:8px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">各游戏独立端口</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">保存端口</button></div><div class="settings-row"><div><div class="settings-label">重启遥测监听</div><div class="settings-desc">手动重启</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button></div></div></div>' +
|
'<div class="settings-section" 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" id="sec-dashboards"><h3>仪表盘管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></div></div>' +
|
'<div class="settings-section" 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" id="sec-plugins"><h3>游戏插件管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">导入 .tsp</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">重新加载</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div></div>' +
|
'<div 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 class="settings-section" id="sec-backup"><h3>数据备份</h3><div class="settings-row"><div><div class="settings-label">导出全量备份</div><div class="settings-desc">打包仪表盘+场景+配置为 .tsb</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">下载 .tsb</button></div></div><div class="settings-row"><div><div class="settings-label">恢复备份</div><div class="settings-desc">从 .tsb 文件恢复</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">导入 .tsb</button></div></div></div>' +
|
'<div class="settings-section" id="sec-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>开发工具</h3><div class="settings-row"><div><div class="settings-label">UI 测试模式</div><div class="settings-desc">本地随机模拟遥测数据</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '><span class="switch-track"></span></label></div></div></div>' +
|
'<div class="settings-section" id="sec-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>系统</h3><div class="settings-row"><div><div class="settings-label">重启 TurboSu</div><div class="settings-desc">重新加载所有代码和配置</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">重启服务</button></div></div></div>' +
|
'<div class="settings-section" 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>关于</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">赛车遥测仪表盘 · 极速中枢 · Yei.J. (AskaEth)<br><a href="https://git.yeij.top/AskaEth/TurboSu" target="_blank" style="font-size:11px;color:var(--accent)">https://git.yeij.top/AskaEth/TurboSu</a></div></div></div></div>' +
|
'<div 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>';
|
'</div></div></div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+338
-41
@@ -2,61 +2,358 @@ const Lang = {
|
|||||||
_current: 'zh',
|
_current: 'zh',
|
||||||
_strings: {
|
_strings: {
|
||||||
zh: {
|
zh: {
|
||||||
home: '首页', dashboard: '仪表盘', scene: '场景', settings: '设置',
|
// Nav
|
||||||
debug: '数据测试', selectGame: '选择游戏', collapsed: '折叠侧边栏',
|
home: '首页', dashboard: '仪表盘', scene: '场景', market: '市场',
|
||||||
connected: '已连接', disconnected: '未连接', speed: '实时速度', rpm: '实时转速',
|
record: '录制回放', debug: '数据测试', settings: '设置',
|
||||||
gear: '当前档位', server: '服务器状态', telemetry: '遥测监听',
|
selectGame: '选择游戏', collapseSidebar: '折叠侧边栏',
|
||||||
connections: 'WebSocket 连接', packets: '数据包接收', lastPacket: '最后数据包',
|
|
||||||
currentGame: '当前游戏', systemOverview: '系统概览', running: '运行中',
|
// Connection
|
||||||
stopped: '已停止', notStarted: '未启动', noData: '暂无数据', client: '个客户端',
|
connected: '已连接', disconnected: '未连接', connectionError: '连接错误',
|
||||||
notSelected: '未选择', packetsUnit: '包', secAgo: '秒前',
|
|
||||||
importTsd: '导入 .tsd', importTss: '导入 .tss', importTsp: '导入 .tsp',
|
// Status cards
|
||||||
export: '导出', delete: '删除', edit: '编辑', render: '渲染', save: '保存',
|
serverStatus: '服务器状态', telemetryStatus: '遥测监听',
|
||||||
cancel: '取消', create: '新建场景', sceneName: '场景名称',
|
wsConnections: 'WebSocket 连接', packetCount: '数据包接收',
|
||||||
search: '搜索仪表盘...', sort: '排序', all: '全部', none: '暂无',
|
lastPacket: '最后数据包', currentGame: '当前游戏',
|
||||||
forward: '数据转发', addTarget: '+ 添加目标', saveActive: '保存并生效',
|
liveSpeed: '实时速度', liveRpm: '实时转速', liveGear: '当前档位',
|
||||||
connectionSettings: '连接设置', telemetryPort: '遥测监听端口',
|
systemOverview: '系统概览', overviewDesc: '实时监控 TurboSu 运行状态与游戏遥测连接',
|
||||||
unifiedPort: '统一端口', perGamePort: '各游戏独立端口',
|
running: '运行中', stopped: '已停止', notStarted: '未启动',
|
||||||
restart: '重启监听', pluginSettings: '游戏插件管理',
|
noData: '暂无数据', notSelected: '未选择',
|
||||||
plugins: '游戏插件', reload: '重新加载', backup: '数据备份',
|
listening: '监听中',
|
||||||
exportBackup: '下载 .tsb', importBackup: '导入 .tsb',
|
|
||||||
|
// 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 测试模式',
|
devTools: '开发工具', testMode: 'UI 测试模式',
|
||||||
system: '系统', restartServer: '重启服务',
|
testModeDesc: '本地随机模拟遥测数据',
|
||||||
about: '关于', author: 'Yei.J. (AskaEth)',
|
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: {
|
en: {
|
||||||
home: 'Home', dashboard: 'Dashboard', scene: 'Scene', settings: 'Settings',
|
home: 'Home', dashboard: 'Dashboard', scene: 'Scene', market: 'Market',
|
||||||
debug: 'Debug', selectGame: 'Select Game', collapsed: 'Collapse',
|
record: 'Record', debug: 'Debug', settings: 'Settings',
|
||||||
connected: 'Connected', disconnected: 'Disconnected', speed: 'Speed', rpm: 'RPM',
|
selectGame: 'Select Game', collapseSidebar: 'Collapse',
|
||||||
gear: 'Gear', server: 'Server', telemetry: 'Telemetry',
|
|
||||||
connections: 'Connections', packets: 'Packets', lastPacket: 'Last Packet',
|
connected: 'Connected', disconnected: 'Disconnected', connectionError: 'Error',
|
||||||
currentGame: 'Game', systemOverview: 'Overview', running: 'Running',
|
|
||||||
stopped: 'Stopped', notStarted: 'Off', noData: 'No Data', client: ' clients',
|
serverStatus: 'Server', telemetryStatus: 'Telemetry',
|
||||||
notSelected: 'None', packetsUnit: 'pkt', secAgo: 's ago',
|
wsConnections: 'Connections', packetCount: 'Packets',
|
||||||
importTsd: 'Import .tsd', importTss: 'Import .tss', importTsp: 'Import .tsp',
|
lastPacket: 'Last Packet', currentGame: 'Game',
|
||||||
export: 'Export', delete: 'Delete', edit: 'Edit', render: 'Render', save: 'Save',
|
liveSpeed: 'Speed', liveRpm: 'RPM', liveGear: 'Gear',
|
||||||
cancel: 'Cancel', create: 'New Scene', sceneName: 'Name',
|
systemOverview: 'System Overview', overviewDesc: 'Real-time monitoring',
|
||||||
search: 'Search...', sort: 'Sort', all: 'All', none: 'None',
|
running: 'Running', stopped: 'Stopped', notStarted: 'Off',
|
||||||
forward: 'Forward', addTarget: '+ Add', saveActive: 'Save',
|
noData: 'No Data', notSelected: 'None',
|
||||||
connectionSettings: 'Connection', telemetryPort: 'Port',
|
listening: 'Listening',
|
||||||
unifiedPort: 'Unified', perGamePort: 'Per Game',
|
|
||||||
restart: 'Restart', pluginSettings: 'Plugins',
|
searchDashboards: 'Search...', sortByName: 'Name', sortByCategory: 'Category',
|
||||||
plugins: 'Plugins', reload: 'Reload', backup: 'Backup',
|
sortByAuthor: 'Author', importTsd: 'Import .tsd', newDashboard: '+ New',
|
||||||
exportBackup: 'Download .tsb', importBackup: 'Import .tsb',
|
noDashboards: 'No dashboards', createFirstDash: 'Create or import your first dashboard',
|
||||||
devTools: 'Dev Tools', testMode: 'Test Mode',
|
noCompatibleDash: 'No compatible dashboards for current game',
|
||||||
system: 'System', restartServer: 'Restart',
|
allCategory: 'All', builtin: 'Built-in', custom: 'Custom',
|
||||||
about: 'About', author: 'Yei.J. (AskaEth)',
|
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() {
|
init() {
|
||||||
this._current = localStorage.getItem('turbosu-lang') || 'zh';
|
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) {
|
t(key) {
|
||||||
return (this._strings[this._current] || this._strings.zh)[key] || 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() {
|
toggle() {
|
||||||
this._current = this._current === 'zh' ? 'en' : 'zh';
|
this._current = this._current === 'zh' ? 'en' : 'zh';
|
||||||
localStorage.setItem('turbosu-lang', this._current);
|
localStorage.setItem('turbosu-lang', this._current);
|
||||||
|
|||||||
+16
-2
@@ -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,6 +44,7 @@ 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);
|
||||||
@@ -47,7 +60,8 @@ const WS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this._ws.onerror = () => {
|
this._ws.onerror = () => {
|
||||||
this._emit('status', 'error');
|
this._emit('status', 'disconnected');
|
||||||
|
this._connected = false;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
||||||
<title>TurboSu - 极速中枢</title>
|
<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>
|
||||||
@@ -25,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">
|
||||||
@@ -45,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>
|
||||||
@@ -53,19 +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>
|
||||||
<a href="#/record" class="nav-item" data-route="record">
|
<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>
|
<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>
|
||||||
<span class="nav-label">录制回放</span>
|
<span class="nav-label" data-i18n="record">录制回放</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#/market" class="nav-item" data-route="market">
|
<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>
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z" fill="currentColor"/></svg>
|
||||||
<span class="nav-label">市场</span>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user