From 9091f2133d7c162a21b9c1ee5423e2fc66078087 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 26 Jul 2026 17:30:19 +0800 Subject: [PATCH] feat: 1,4,6 - scene canvas editor, systemd service, F1 composite dashboard - Scene editor: drag-drop dashboards from sidebar to canvas, reposition, delete placements, save/load - Systemd service: turbosu.service with frpc dependency, restart on failure - F1 composite: speed/RPM/lap/gear/throttle/brake/fuel/DRS, 16:9, NEXT ART font --- dashboards/f1_composite/index.html | 177 +++++++++++++ dashboards/f1_composite/manifest.json | 14 + ecosystem/turbosu.service | 17 ++ static/css/main.css | 10 + static/js/pages/scene.js | 358 +++++++++++++++----------- 5 files changed, 420 insertions(+), 156 deletions(-) create mode 100644 dashboards/f1_composite/index.html create mode 100644 dashboards/f1_composite/manifest.json create mode 100644 ecosystem/turbosu.service diff --git a/dashboards/f1_composite/index.html b/dashboards/f1_composite/index.html new file mode 100644 index 0000000..75f3f27 --- /dev/null +++ b/dashboards/f1_composite/index.html @@ -0,0 +1,177 @@ + + + + + +TurboSu - F1 Composite + + + +
+
DRS
+ +
+
0
+
KM/H
+
+ 0 + RPM +
+
+ +
+
+
LAP
+
0
+
+
+
0:00.0
+
+
+
BEST
+
--:--.-
+
+
+ +
N
+ +
+
THR
+
BRK
+
+
FUEL100%
+
+
+
+ +
+
G-LAT 0.00
+
G-LON 0.00
+
+
+ + + + diff --git a/dashboards/f1_composite/manifest.json b/dashboards/f1_composite/manifest.json new file mode 100644 index 0000000..7108c69 --- /dev/null +++ b/dashboards/f1_composite/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "f1_composite", + "name": "F1 复合仪表盘", + "category": "f1", + "description": "F1 专属复合仪表:速度+RPM大字+档位圆环+油门刹车行程+油量+圈速", + "author": "Yei.J. (AskaEth)", + "version": "1.0.0", + "supported_games": "f1_24", + "config": { + "icon": "🏎️", + "aspect_ratio": "16:9", + "render_mode": "contain" + } +} diff --git a/ecosystem/turbosu.service b/ecosystem/turbosu.service new file mode 100644 index 0000000..c900a13 --- /dev/null +++ b/ecosystem/turbosu.service @@ -0,0 +1,17 @@ +[Unit] +Description=TurboSu Service +After=network-online.target +Requires=frpc.service + +[Service] +Type=simple +User=Aska +WorkingDirectory=/home/Aska/Project/TurboSu +ExecStart=/home/Aska/Project/TurboSu/venv/bin/python /home/Aska/Project/TurboSu/app.py +Restart=on-failure +RestartSec=5 +StandardOutput=append:/home/Aska/Project/TurboSu/logs/turbosu.log +StandardError=append:/home/Aska/Project/TurboSu/logs/turbosu.log + +[Install] +WantedBy=multi-user.target diff --git a/static/css/main.css b/static/css/main.css index 14100a5..a987970 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -500,6 +500,7 @@ border-radius: var(--radius-sm); cursor: move; display: flex; + flex-direction: column; align-items: center; justify-content: center; font-size: 12px; @@ -507,7 +508,16 @@ background: var(--accent-container); min-width: 80px; min-height: 50px; + user-select: none; + overflow: hidden; } +.ep-icon { font-size: 24px; } +.ep-name { font-size: 11px; color: var(--text-secondary); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90%; } +.ep-actions { position: absolute; top: 2px; right: 2px; } + +.editor-dash-item:active { opacity: 0.7; } + +.forward-toggle input { margin-right: 4px; } /* ===== Debug Page ===== */ .debug-container { diff --git a/static/js/pages/scene.js b/static/js/pages/scene.js index ce237fe..9596bdf 100644 --- a/static/js/pages/scene.js +++ b/static/js/pages/scene.js @@ -3,7 +3,14 @@ const PageScene = { _currentGameId: null, _currentGameName: '', _editorSceneId: null, - _editorData: null, + _editorCanvasIdx: 0, + _placements: [], + _dashboards: [], + _dragItem: null, + _dragOffsetX: 0, + _dragOffsetY: 0, + _resizeItem: null, + _resizeDir: '', init() { this._el = document.getElementById('page-scene'); @@ -12,208 +19,247 @@ const PageScene = { async render() { const cfg = await API.getConfig(); this._currentGameId = cfg.selected_game_id; - if (this._currentGameId) { const games = await API.getGames(); const game = games.find(g => g.id === this._currentGameId); this._currentGameName = game ? game.name : this._currentGameId; + this._dashboards = await API.getDashboards('all'); } - this._el.innerHTML = this._template(); - await this._loadScenes(); + this._showSceneList(); this._bindEvents(); }, - async _loadScenes() { + async _showSceneList() { + document.getElementById('scene-list-container').style.display = 'block'; + document.getElementById('scene-editor-container').style.display = 'none'; const grid = document.getElementById('scene-grid'); if (!grid) return; - try { const scenes = await API.getScenes(this._currentGameId); if (!scenes || scenes.length === 0) { - grid.innerHTML = ` -
- -

暂无场景

-

点击"新建场景"创建你的第一个场景布局

-
`; + grid.innerHTML = `

暂无场景

点击"新建场景"创建你的场景

`; return; } - grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join(''); - } catch (e) { - console.error('Failed to load scenes:', e); - } + } catch (e) { grid.innerHTML = '

加载失败

'; } }, - _bindEvents() { - document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor()); - document.getElementById('btn-import-scene')?.addEventListener('click', () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.tss'; - input.onchange = async (e) => { - const file = e.target.files[0]; - if (!file) return; - const formData = new FormData(); - formData.append('file', file); - try { - const res = await fetch('/api/scenes/import', { method: 'POST', body: formData }); - const data = await res.json(); - if (data.ok) { Toast.show('场景导入成功', 'success'); this._loadScenes(); } - else Toast.show('导入失败', 'error'); - } catch (err) { Toast.show('导入失败', 'error'); } - }; - input.click(); - }); - - const grid = document.getElementById('scene-grid'); - if (grid) { - grid.addEventListener('click', (e) => { - const card = e.target.closest('[data-scene-id]'); - if (!card) return; - const action = card.dataset.action; - const sceneId = card.dataset.sceneId; - - if (action === 'render') { - this._renderScene(sceneId); - } else if (action === 'edit') { - e.stopPropagation(); - this._showEditor(sceneId); - } else if (action === 'delete') { - e.stopPropagation(); - this._deleteScene(sceneId); - } else if (action === 'export') { - e.stopPropagation(); - this._exportScene(sceneId); - } - }); - } - }, - - async _showEditor(sceneId) { + async _openEditor(sceneId) { + this._editorSceneId = sceneId; + this._editorCanvasIdx = 0; + this._placements = []; let scene = null; if (sceneId) { scene = await API.getScene(sceneId); - if (!scene) return; - } - - this._editorSceneId = sceneId; - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.innerHTML = this._editorTemplate(scene); - document.body.appendChild(modal); - - modal.querySelector('.modal-overlay, .modal-actions .btn-secondary') - ?.addEventListener('click', (e) => { - if (e.target === modal || e.target.matches('.btn-secondary')) { - modal.remove(); - } - }); - - modal.querySelector('.btn-primary')?.addEventListener('click', async () => { - const name = modal.querySelector('#edit-scene-name').value || 'New Scene'; - const desc = modal.querySelector('#edit-scene-desc').value || ''; - const gameId = this._currentGameId || ''; - - const data = { name, description: desc, game_id: gameId }; - if (this._editorSceneId) { - await API.updateScene(this._editorSceneId, data); - } else { - await API.createScene(data); + if (scene && scene.canvases && scene.canvases[0]) { + this._placements = scene.canvases[0].placements || []; } - modal.remove(); - Toast.show('场景保存成功', 'success'); - this.render(); + } + const listC = document.getElementById('scene-list-container'); + const editorC = document.getElementById('scene-editor-container'); + listC.style.display = 'none'; + editorC.style.display = 'flex'; + this._renderCanvasEditor(scene); + }, + + _renderCanvasEditor(scene) { + const nameInput = document.getElementById('edit-scene-name'); + if (nameInput && scene) nameInput.value = scene.name || ''; + this._renderDashboardList(); + this._renderCanvas(); + }, + + _renderDashboardList() { + const list = document.getElementById('editor-dash-list'); + if (!list) return; + const filtered = this._dashboards.filter(d => d.supported_games === 'all' || (d.supported_games||'').split(',').includes(this._currentGameId)); + list.innerHTML = filtered.map(d => + `
+ ${d.icon||'📊'} + ${d.name} +
` + ).join('') || '

无兼容仪表盘

'; + + list.querySelectorAll('.editor-dash-item').forEach(el => { + el.addEventListener('dragstart', (e) => { + this._dragItem = { dashId: el.dataset.dashId, isNew: true }; + }); }); }, - async _renderScene(sceneId) { + _renderCanvas() { + const canvas = document.getElementById('editor-canvas'); + if (!canvas) return; + const cw = 1920, ch = 1080; + const scale = Math.min(canvas.parentElement.clientWidth / cw, 1); + canvas.style.width = (cw * scale) + 'px'; + canvas.style.height = (ch * scale) + 'px'; + + canvas.innerHTML = this._placements.map((p, idx) => { + const dash = this._dashboards.find(d => d.id === p.dashboard_id); + const name = dash ? dash.name : p.dashboard_id; + const icon = dash ? (dash.icon || '📊') : '❓'; + return `
+
${icon}
+
${name}
+
+ +
+
`; + }).join(''); + + canvas.querySelectorAll('.editor-placement').forEach(el => { + const idx = parseInt(el.dataset.idx); + el.addEventListener('mousedown', (e) => this._startDragPlace(e, idx, el)); + el.addEventListener('click', (e) => { + if (e.target.closest('[data-action="remove-place"]')) { + this._placements.splice(idx, 1); + this._renderCanvas(); + } + }); + }); + + canvas.addEventListener('dragover', (e) => e.preventDefault()); + canvas.addEventListener('drop', (e) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / scale; + const y = (e.clientY - rect.top) / scale; + if (this._dragItem && this._dragItem.isNew) { + this._placements.push({ + dashboard_id: this._dragItem.dashId, + x, y, width: 300, height: 200, z_index: this._placements.length + }); + this._renderCanvas(); + } + }); + }, + + _startDragPlace(e, idx, el) { + if (e.target.closest('button')) return; + const canvasEl = document.getElementById('editor-canvas'); + const rect = canvasEl.getBoundingClientRect(); + const scale = parseFloat(canvasEl.style.width) / 1920; + const startX = e.clientX, startY = e.clientY; + const origP = { ...this._placements[idx] }; + + const onMove = (ev) => { + const dx = (ev.clientX - startX) / scale; + const dy = (ev.clientY - startY) / scale; + this._placements[idx].x = origP.x + dx; + this._placements[idx].y = origP.y + dy; + el.style.left = (this._placements[idx].x * scale) + 'px'; + el.style.top = (this._placements[idx].y * scale) + 'px'; + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + + async _saveScene() { + const name = document.getElementById('edit-scene-name')?.value || 'New Scene'; + const data = { + name, + game_id: this._currentGameId || '', + canvases: [{ width: 1920, height: 1080, label: '16:9', placements: this._placements }] + }; + try { + if (this._editorSceneId) { + await API.updateScene(this._editorSceneId, data); + } else { + const created = await API.createScene(data); + this._editorSceneId = created.id; + } + Toast.show('场景保存成功', 'success'); + this._showSceneList(); + } catch (e) { Toast.show('保存失败: ' + e.message, 'error'); } + }, + + _bindEvents() { + document.getElementById('btn-new-scene')?.addEventListener('click', () => this._openEditor(null)); + document.getElementById('btn-import-scene')?.addEventListener('click', () => { + const input = document.createElement('input'); input.type = 'file'; input.accept = '.tss'; + input.onchange = async (e) => { + const file = e.target.files[0]; if (!file) return; + const fd = new FormData(); fd.append('file', file); + const res = await fetch('/api/scenes/import', { method: 'POST', body: fd }); + const data = await res.json(); + if (data.ok) { Toast.show('场景导入成功', 'success'); this._showSceneList(); } + else Toast.show('导入失败', 'error'); + }; input.click(); + }); + + document.getElementById('btn-back-to-list')?.addEventListener('click', () => this._showSceneList()); + document.getElementById('btn-save-scene')?.addEventListener('click', () => this._saveScene()); + + document.getElementById('scene-grid')?.addEventListener('click', (e) => { + const card = e.target.closest('[data-scene-id]'); + if (!card) return; + const action = card.dataset.action, sceneId = card.dataset.sceneId; + if (action === 'render') this._renderScene(sceneId); + else if (action === 'edit') this._openEditor(sceneId); + else if (action === 'delete') { if (confirm('确定删除?')) { API.deleteScene(sceneId); this._showSceneList(); } } + else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); } + }); + }, + + _renderScene(sceneId) { const url = `${location.origin}/scene/${sceneId}`; window.open(url, '_blank'); - navigator.clipboard.writeText(url).then(() => { - Toast.show('场景链接已复制,可在局域网设备访问', 'success'); - }).catch(() => {}); - }, - - async _deleteScene(sceneId) { - if (!confirm('确定删除此场景?')) return; - await API.deleteScene(sceneId); - Toast.show('场景已删除', 'info'); - this._loadScenes(); - }, - - _exportScene(sceneId) { - const a = document.createElement('a'); - a.href = `/api/scenes/${sceneId}/export`; - a.download = `${sceneId}.tss`; - a.click(); - Toast.show('正在下载 .tss 文件...', 'info'); + navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {}); }, _sceneCardHtml(scene) { const canvasCount = (scene.canvases || []).length; - return ` -
+ return `
${scene.name}
-
${this._currentGameName || '未关联游戏'}
-
- ${canvasCount} 个画布 - ${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''} -
-
- ${(scene.canvases || []).map(c => `${c.label || c.width + 'x' + c.height}`).join('')} -
-
+
${this._currentGameName || '未关联游戏'}
+
${canvasCount} 画布${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}
+
${(scene.canvases||[]).map(c => `${c.label||c.width+'x'+c.height}`).join('')}
+
-
-
`; - }, - - _editorTemplate(scene) { - const name = scene ? scene.name : ''; - const desc = scene ? scene.description : ''; - return ` - `; +
`; }, _template() { return ` -
+

场景管理

-

- ${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'} -

+

${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}

-
+
- +
-
`; +
+ + `; } };