diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ba6a46d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 9527 20777/udp +CMD ["python", "app.py"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f5a9182 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' +services: + turbosu: + build: . + ports: + - "9527:9527" + - "20777:20777/udp" + volumes: + - ./data:/app/data + - ./logs:/app/logs + - ./dashboards:/app/dashboards + - ./games:/app/games + restart: unless-stopped diff --git a/server/api.py b/server/api.py index 7c5575e..19a291f 100644 --- a/server/api.py +++ b/server/api.py @@ -313,6 +313,50 @@ async def api_reload_game_plugins(): return {"ok": True, "count": len(game_plugin_manager.list_all())} +# ---- Logs ---- +@router.get("/logs") +async def api_get_logs(lines: int = 50): + from pathlib import Path + log_file = Path(__file__).resolve().parent.parent / "logs" / "turbosu.log" + if not log_file.exists(): + return {"lines": []} + try: + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + return {"lines": [l.rstrip() for l in all_lines[-lines:]]} + except Exception: + return {"lines": []} + + +# ---- Full Backup (.tsb) ---- +@router.get("/backup/export") +async def api_export_backup(): + import io, zipfile + from fastapi.responses import Response + + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + base = Path(__file__).resolve().parent.parent + data_dir = base / "data" + if data_dir.exists(): + for f in data_dir.rglob("*"): + if f.is_file() and "__pycache__" not in str(f): + zf.write(f, f.relative_to(base)) + dash_dir = base / "dashboards" + if dash_dir.exists(): + for f in dash_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(base)) + games_dir = base / "games" / "user" + if games_dir.exists(): + for f in games_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(base)) + + return Response(content=buf.getvalue(), media_type="application/zip", + headers={"Content-Disposition": "attachment; filename=turbosu_backup.tsb"}) + + # ---- Data Forwarding ---- @router.get("/forward") async def api_list_forward_targets(): diff --git a/static/js/pages/dashboard.js b/static/js/pages/dashboard.js index 039ffad..b9421c3 100644 --- a/static/js/pages/dashboard.js +++ b/static/js/pages/dashboard.js @@ -57,6 +57,14 @@ const PageDashboard = { themes = themes.filter(t => this._isGameSupported(t)); } + const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || ''; + if (searchQuery) { + themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); + } + + const sortBy = document.getElementById('dash-sort')?.value || 'name'; + themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||'')); + if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘'); return; @@ -115,6 +123,9 @@ const PageDashboard = { }); } + document.getElementById('dash-search')?.addEventListener('input', () => this._loadThemes()); + document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes()); + document.getElementById('btn-import-dashboard')?.addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; @@ -188,6 +199,14 @@ const PageDashboard = {
+
+ + +
`; diff --git a/static/js/pages/debug.js b/static/js/pages/debug.js index ada8dcb..191f1ca 100644 --- a/static/js/pages/debug.js +++ b/static/js/pages/debug.js @@ -33,6 +33,17 @@ const PageDebug = { document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => { this._autoScroll = e.target.checked; }); + + document.getElementById('debug-show-log')?.addEventListener('click', async () => { + try { + const res = await fetch('/api/logs?lines=100'); + const data = await res.json(); + const el = document.getElementById('debug-raw'); + if (el) el.innerHTML = data.lines.map(l => + `${l}` + ).join('\n'); + } catch (e) {} + }); }, _onTelemetry(data) { @@ -97,6 +108,7 @@ const PageDebug = { +
diff --git a/static/js/pages/settings.js b/static/js/pages/settings.js index e91eab7..7cd3aa8 100644 --- a/static/js/pages/settings.js +++ b/static/js/pages/settings.js @@ -85,6 +85,13 @@ const PageSettings = { Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error'); }; input.click(); }); + + document.getElementById('btn-export-backup')?.addEventListener('click', () => { + const a = document.createElement('a'); + a.href = '/api/backup/export'; + a.download = 'turbosu_backup.tsb'; + a.click(); + }); }, _addForward() { @@ -128,6 +135,8 @@ const PageSettings = { '

仪表盘管理

右键卡片导出
' + + '

数据备份

导出全量备份
打包仪表盘+场景+配置为 .tsb 文件
' + + '

游戏插件管理

' + this._gamePluginListHtml(games) + '
社区插件开发指南:
1. 创建包含 manifest.json + parser.py 的文件夹
2. manifest.json 定义元信息,parser.py 实现 get_parser() 函数
3. 返回对象实现 game_id() 和 parse(data, addr) 方法
4. 打包 zip 改后缀 .tsp 即可导入
5. TelemetryData 字段参考 server/telemetry/data.py
' + '

关于

TurboSu
赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)
' +