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
+
+
+
+
+
+
+
+
+
+
+
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 `
-
-
${scene ? '编辑场景' : '新建场景'}
-
-
-
-
-
-
-
-
-
- 提示:保存后可在场景编辑器中添加仪表盘、调整布局和画布比例。
-
-
-
-
-
-
`;
+
`;
},
_template() {
return `
-