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())}
|
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 ----
|
# ---- Data Forwarding ----
|
||||||
@router.get("/forward")
|
@router.get("/forward")
|
||||||
async def api_list_forward_targets():
|
async def api_list_forward_targets():
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ const PageDashboard = {
|
|||||||
themes = themes.filter(t => this._isGameSupported(t));
|
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) {
|
if (themes.length === 0) {
|
||||||
gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘');
|
gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘');
|
||||||
return;
|
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', () => {
|
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
@@ -188,6 +199,14 @@ const PageDashboard = {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-main">
|
<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 id="dash-theme-grid" class="theme-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ const PageDebug = {
|
|||||||
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
|
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
|
||||||
this._autoScroll = e.target.checked;
|
this._autoScroll = e.target.checked;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('debug-show-log')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/logs?lines=100');
|
||||||
|
const data = await res.json();
|
||||||
|
const el = document.getElementById('debug-raw');
|
||||||
|
if (el) el.innerHTML = data.lines.map(l =>
|
||||||
|
`<span style="color:${l.includes('ERROR')?'red':l.includes('WARNING')?'#f90':l.includes('INFO')?'#888':'#555'}">${l}</span>`
|
||||||
|
).join('\n');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_onTelemetry(data) {
|
_onTelemetry(data) {
|
||||||
@@ -97,6 +108,7 @@ const PageDebug = {
|
|||||||
<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> 自动滚动
|
||||||
</label>
|
</label>
|
||||||
|
<button id="debug-show-log" class="btn btn-sm btn-secondary">📄 日志</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;">
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ const PageSettings = {
|
|||||||
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
|
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error');
|
||||||
}; input.click();
|
}; input.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-export-backup')?.addEventListener('click', () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/api/backup/export';
|
||||||
|
a.download = 'turbosu_backup.tsb';
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_addForward() {
|
_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 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 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>' +
|
'<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