feat: 2,3,7,8,9 - search/sort, Docker, log viewer, .tsb backup

- Dashboard browser: search box + sort dropdown (name/category/author)
- Dockerfile + docker-compose.yml
- Debug page: log viewer button (/api/logs)
- Settings: .tsb full backup export (dashboards+scenes+config)
- F1 composite dashboard previously included
This commit is contained in:
2026-07-26 17:32:09 +08:00
parent 9091f2133d
commit a08120c568
6 changed files with 104 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 9527 20777/udp
CMD ["python", "app.py"]
+13
View File
@@ -0,0 +1,13 @@
version: '3.8'
services:
turbosu:
build: .
ports:
- "9527:9527"
- "20777:20777/udp"
volumes:
- ./data:/app/data
- ./logs:/app/logs
- ./dashboards:/app/dashboards
- ./games:/app/games
restart: unless-stopped
+44
View File
@@ -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():
+19
View File
@@ -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 = {
</div>
</div>
<div class="dashboard-main">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
<input id="dash-search" type="text" placeholder="搜索仪表盘..." style="flex:1;max-width:300px">
<select id="dash-sort" style="width:120px">
<option value="name">名称</option>
<option value="category">分类</option>
<option value="author">作者</option>
</select>
</div>
<div id="dash-theme-grid" class="theme-grid"></div>
</div>
</div>`;
+12
View File
@@ -33,6 +33,17 @@ const PageDebug = {
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
this._autoScroll = e.target.checked;
});
document.getElementById('debug-show-log')?.addEventListener('click', async () => {
try {
const res = await fetch('/api/logs?lines=100');
const data = await res.json();
const el = document.getElementById('debug-raw');
if (el) el.innerHTML = data.lines.map(l =>
`<span style="color:${l.includes('ERROR')?'red':l.includes('WARNING')?'#f90':l.includes('INFO')?'#888':'#555'}">${l}</span>`
).join('\n');
} catch (e) {}
});
},
_onTelemetry(data) {
@@ -97,6 +108,7 @@ const PageDebug = {
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动
</label>
<button id="debug-show-log" class="btn btn-sm btn-secondary">📄 日志</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
<div style="overflow-y:auto;">
+9
View File
@@ -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 = {
'<div class="settings-section"><h3>仪表盘管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">右键卡片导出</span></div></div>' +
'<div class="settings-section"><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>' +
'<div class="settings-section"><h3>游戏插件管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">导入 .tsp</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">重新加载</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div><div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6"><strong style="color:var(--text-secondary)">社区插件开发指南:</strong><br>1. 创建包含 manifest.json + parser.py 的文件夹<br>2. manifest.json 定义元信息,parser.py 实现 get_parser() 函数<br>3. 返回对象实现 game_id() 和 parse(data, addr) 方法<br>4. 打包 zip 改后缀 .tsp 即可导入<br>5. TelemetryData 字段参考 server/telemetry/data.py</div></div>' +
'<div class="settings-section"><h3>关于</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)</div></div></div></div>' +