refactor: move record/playback to dedicated page, restore test mode in settings

- New '录制回放' page with sidebar nav entry
- Recording: start/stop/auto, file list with play/export/delete
- Playback: visual bar indicator, opens dashboards to see replayed data
- Settings: restored dev tools section with test mode toggle
This commit is contained in:
2026-07-26 19:10:48 +08:00
parent 3514612f7a
commit 7e9211507b
5 changed files with 103 additions and 8 deletions
+4 -6
View File
@@ -481,11 +481,9 @@ async def api_export_recording(filename: str):
return FileResponse(path, media_type="application/octet-stream", filename=filename) return FileResponse(path, media_type="application/octet-stream", filename=filename)
@router.post("/recording/import") @router.delete("/recording/{filename}/delete")
async def api_import_recording(file: UploadFile = File(...)): async def api_delete_recording(filename: str):
from server.recorder import RECORDINGS_DIR from server.recorder import RECORDINGS_DIR
if not file.filename or not file.filename.endswith('.tsr'): path = RECORDINGS_DIR / filename
raise HTTPException(400, "Only .tsr files are accepted") if path.exists(): path.unlink()
dest = RECORDINGS_DIR / file.filename
dest.write_bytes(await file.read())
return {"ok": True} return {"ok": True}
+89
View File
@@ -0,0 +1,89 @@
const PageRecord = {
_el: null,
init() { this._el = document.getElementById('page-record'); },
async render() {
this._el.innerHTML = this._template();
this._bindEvents();
this._loadRecordings();
},
async _loadRecordings() {
try {
const res = await fetch('/api/recordings');
const list = await res.json();
const el = document.getElementById('rec-file-list');
if (!el) return;
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:13px;padding:20px">暂无录制文件</p>'; return; }
el.innerHTML = list.map(f => {
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>';
}).join('');
} catch (e) {}
},
_bindEvents() {
document.getElementById('btn-rec-start')?.addEventListener('click', async () => {
await fetch('/api/record/start', { method: 'POST' });
this._updateRecUI(true);
});
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
await fetch('/api/record/stop', { method: 'POST' });
this._updateRecUI(false);
this._loadRecordings();
});
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
await fetch('/api/record/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: e.target.checked }) });
});
document.getElementById('btn-import-rec')?.addEventListener('click', () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsr';
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
await fetch('/api/recording/import', { method: 'POST', body: fd });
Toast.show('导入成功', 'success'); this._loadRecordings();
}; input.click();
});
document.getElementById('rec-file-list')?.addEventListener('click', async (e) => {
const playBtn = e.target.closest('.btn-play-rec');
const delBtn = e.target.closest('.btn-del-rec');
if (playBtn) this._playRecording(playBtn.dataset.file);
if (delBtn && confirm('删除?')) {
await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' });
this._loadRecordings();
}
});
document.getElementById('btn-playback-stop')?.addEventListener('click', async () => {
await fetch('/api/playback/stop', { method: 'POST' });
document.getElementById('playback-bar').style.display = 'none';
});
},
_updateRecUI(recording) {
document.getElementById('btn-rec-start').style.display = recording ? 'none' : '';
document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none';
document.getElementById('rec-status').textContent = recording ? '录制中...' : '就绪';
},
async _playRecording(filename) {
document.getElementById('playback-file').textContent = filename;
document.getElementById('playback-bar').style.display = 'flex';
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
Toast.show('回放已开始,打开仪表盘查看数据', 'info');
},
_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>' +
'<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 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 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>';
}
};
window.PageRecord = PageRecord;
+2 -2
View File
@@ -242,7 +242,6 @@ const PageSettings = {
{ id: 'sec-plugins', label: '游戏插件' }, { id: 'sec-plugins', label: '游戏插件' },
{ id: 'sec-backup', label: '数据备份' }, { id: 'sec-backup', label: '数据备份' },
{ id: 'sec-dev', label: '开发工具' }, { id: 'sec-dev', label: '开发工具' },
{ id: 'sec-record', label: '录制回放' },
{ id: 'sec-system', label: '系统' }, { id: 'sec-system', label: '系统' },
]; ];
@@ -260,7 +259,8 @@ const PageSettings = {
'<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>数据备份</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-record"><h3>录制与回放</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:10px">录制原始 UDP 流到 .tsr 文件,离线回放调试仪表盘</p><div class="settings-row"><div><div class="settings-label">录制状态</div><div class="settings-desc"><span id="rec-status">未启动</span></div></div><div class="settings-control"><button id="btn-rec-start" class="btn btn-secondary btn-sm">开始录制</button><button id="btn-rec-stop" class="btn btn-danger btn-sm" style="display:none">停止</button></div></div><div style="margin-top:10px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:6px">回放文件</h4><div id="recording-list" style="max-height:200px;overflow-y:auto"></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-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>系统</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>' +
+1
View File
@@ -4,6 +4,7 @@ const Router = {
dashboard: PageDashboard, dashboard: PageDashboard,
scene: PageScene, scene: PageScene,
debug: PageDebug, debug: PageDebug,
record: PageRecord,
settings: PageSettings, settings: PageSettings,
}, },
_currentPage: 'home', _currentPage: 'home',
+7
View File
@@ -57,6 +57,10 @@
<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">数据测试</span>
</a> </a>
<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>
<span class="nav-label">录制回放</span>
</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">设置</span>
@@ -97,6 +101,8 @@
</div> </div>
<div id="page-debug" class="page"> <div id="page-debug" class="page">
</div> </div>
<div id="page-record" class="page">
</div>
<div id="page-settings" class="page"> <div id="page-settings" class="page">
</div> </div>
</div> </div>
@@ -114,6 +120,7 @@
<script src="/static/js/pages/dashboard.js"></script> <script src="/static/js/pages/dashboard.js"></script>
<script src="/static/js/pages/scene.js"></script> <script src="/static/js/pages/scene.js"></script>
<script src="/static/js/pages/debug.js"></script> <script src="/static/js/pages/debug.js"></script>
<script src="/static/js/pages/record.js"></script>
<script src="/static/js/pages/settings.js"></script> <script src="/static/js/pages/settings.js"></script>
<script src="/static/js/router.js"></script> <script src="/static/js/router.js"></script>
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>