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:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
Reference in New Issue
Block a user