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
This commit is contained in:
2026-07-26 17:30:19 +08:00
parent e28be26ab3
commit 9091f2133d
5 changed files with 420 additions and 156 deletions
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<title>TurboSu - F1 Composite</title>
<style>
@font-face{font-family:'NEXT ART';src:url('/static/fonts/NEXT_ART_Heavy.otf') format('opentype')}
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;
font-family:'NEXT ART',monospace}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
#content{position:relative;width:100%;height:100%;background:#0a0a0f}
#drs{position:absolute;right:4%;top:4%;padding:6px 18px;
border:1px solid rgba(255,255,255,.08);border-radius:4px;
font-size:min(1.6vw,24px);color:rgba(255,255,255,.15);letter-spacing:.3em;opacity:.5}
#left-col{position:absolute;left:4%;top:10%;width:58%}
#speed-val{font-size:min(16vw,300px);font-weight:200;line-height:.85;letter-spacing:-.02em}
#speed-unit{font-size:min(2.5vw,36px);color:rgba(255,255,255,.15);letter-spacing:.35em;margin-top:4px}
#rpm-row{display:flex;align-items:baseline;gap:12px;margin-top:1.5vh}
#rpm-val{font-size:min(8vw,140px);font-weight:200;line-height:1;letter-spacing:-.02em}
#rpm-unit{font-size:min(1.8vw,26px);color:rgba(255,255,255,.15);letter-spacing:.2em}
#lap-area{position:absolute;left:4%;top:58%;display:flex;align-items:flex-end;gap:min(4vw,60px)}
#lap-box{text-align:center}
#lap-label{font-size:min(1.4vw,20px);color:rgba(255,255,255,.15);letter-spacing:.25em;margin-bottom:2px}
#lap-num{font-size:min(5vw,72px);font-weight:200;line-height:1;letter-spacing:-.02em}
#lap-time{font-size:min(4vw,56px);font-weight:200;font-variant-numeric:tabular-nums;letter-spacing:-.02em;color:rgba(255,255,255,.6)}
#best-box{text-align:center}
#best-label{font-size:min(1.3vw,18px);color:rgba(255,255,255,.1);letter-spacing:.25em;margin-bottom:2px}
#best-time{font-size:min(2.8vw,40px);font-weight:200;font-variant-numeric:tabular-nums;color:rgba(255,255,255,.25)}
#gear-area{position:absolute;left:50%;transform:translateX(-50%);bottom:8%}
#gear-box{width:min(20vw,260px);height:min(20vw,260px);border-radius:50%;
border:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:center;
background:rgba(255,255,255,.02)}
#gear-val{font-size:min(14vw,220px);font-weight:200;line-height:1;transition:color .15s}
#right-col{position:absolute;right:4%;top:14%;width:20%}
.pedal{margin-bottom:3vh}
.pedal .pl{font-size:min(1.4vw,22px);color:rgba(255,255,255,.2);margin-bottom:6px;letter-spacing:.25em}
.pedal .pb{height:min(1.4vw,22px);background:rgba(255,255,255,.04);overflow:hidden}
.pedal .pf{height:100%;transition:width .08s linear}
#tf{background:rgba(255,255,255,.75)}#bf{background:rgba(255,255,255,.3)}
#fuel-box{margin-top:6vh}
#fuel-label{font-size:min(1.4vw,22px);color:rgba(255,255,255,.2);margin-bottom:6px;display:flex;justify-content:space-between;letter-spacing:.25em}
#fuel-label span:last-child{color:rgba(255,255,255,.35)}
#fuel-bar{height:min(1.4vw,22px);background:rgba(255,255,255,.04);overflow:hidden}
#fuel-fill{height:100%;background:rgba(255,255,255,.15);transition:width .5s}
#accel{position:absolute;right:28%;top:4%;text-align:right}
#accel div{font-size:min(1vw,15px);color:rgba(255,255,255,.08);letter-spacing:.15em;font-variant-numeric:tabular-nums}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div id="drs">DRS</div>
<div id="left-col">
<div id="speed-val">0</div>
<div id="speed-unit">KM/H</div>
<div id="rpm-row">
<span id="rpm-val">0</span>
<span id="rpm-unit">RPM</span>
</div>
</div>
<div id="lap-area">
<div id="lap-box">
<div id="lap-label">LAP</div>
<div id="lap-num">0</div>
</div>
<div>
<div id="lap-time">0:00.0</div>
</div>
<div id="best-box">
<div id="best-label">BEST</div>
<div id="best-time">--:--.-</div>
</div>
</div>
<div id="gear-area"><div id="gear-box"><span id="gear-val">N</span></div></div>
<div id="right-col">
<div class="pedal"><div class="pl">THR</div><div class="pb"><div class="pf" id="tf" style="width:0%"></div></div></div>
<div class="pedal"><div class="pl">BRK</div><div class="pb"><div class="pf" id="bf" style="width:0%"></div></div></div>
<div id="fuel-box">
<div id="fuel-label"><span>FUEL</span><span id="fuel-pct">100%</span></div>
<div id="fuel-bar"><div id="fuel-fill" style="width:100%"></div></div>
</div>
</div>
<div id="accel">
<div>G-LAT <span id="ax">0.00</span></div>
<div>G-LON <span id="az">0.00</span></div>
</div>
</div></div></div>
<script>
const M={aspect_ratio:"16:9",render_mode:"contain"};
(function(){
const box=document.getElementById('box'),sV=document.getElementById('speed-val'),
rV=document.getElementById('rpm-val'),gV=document.getElementById('gear-val'),
tF=document.getElementById('tf'),bF=document.getElementById('bf'),
fF=document.getElementById('fuel-fill'),fP=document.getElementById('fuel-pct'),
lN=document.getElementById('lap-num'),lT=document.getElementById('lap-time'),
bT=document.getElementById('best-time'),gBox=document.getElementById('gear-box'),
aX=document.getElementById('ax'),aZ=document.getElementById('az');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt,lR=-1,lG=-999;
function fmt(t){if(!t||t<=0)return'0:00.0';const m=Math.floor(t/60),s=(t%60).toFixed(1);return m+':'+s.padStart(4,'0')}
function fmtB(t){if(!t||t<=0)return'--:--.-';return fmt(t)}
function pr(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2)return +p[0]/+p[1]}
function apply(){
const rt=pr(M.aspect_ratio||'16:9'),md=M.render_mode||'contain';
if(!rt){box.style.width='100vw';box.style.height='100vh';box.className='fill';return}
const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;
if(md==='cover'){cw=vw;ch=vw/rt;box.className='cover'}
else{box.className='contain';cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}
box.style.width=cw+'px';box.style.height=ch+'px'
}
function cn(){ws=new WebSocket(wsUrl);ws.onopen=()=>{if(rt){clearTimeout(rt);rt=null}}
ws.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==='telemetry')up(m.data)}catch(e){}}
ws.onclose=()=>{rt=setTimeout(cn,2000)}}
function gearStr(g){
if(g===0||!g)return'N';
if(g>=1&&g<=8)return String(g);
return String(g);
}
function up(d){
const rpm=d.rpm||0,mr=d.max_rpm||15000,g=d.gear||0;
sV.textContent=Math.round(d.speed_kmh||0);
rV.textContent=Math.round(rpm);
if(Math.abs(rpm-lR)>50||g!==lG){
lR=rpm;lG=g;
const pct=mr>0?Math.min(rpm/mr,1):0;
gBox.style.borderColor=pct>.9?'rgba(255,50,35,.5)':'rgba(255,255,255,.08)';
gV.style.color=pct>.9?'rgba(255,50,35,.95)':'#fff';
}
gV.textContent=gearStr(g);
tF.style.width=((d.throttle||0)*100).toFixed(0)+'%';
bF.style.width=((d.brake||0)*100).toFixed(0)+'%';
const fp=Math.round((d.fuel||1)*100);
fF.style.width=fp+'%';fP.textContent=fp+'%';
lN.textContent=d.lap_number||0;
lT.textContent=fmt(d.lap_time);
bT.textContent=fmtB(d.best_lap);
aX.textContent=(d.acceleration_x||0).toFixed(2);
aZ.textContent=(d.acceleration_z||0).toFixed(2);
}
apply();window.addEventListener('resize',()=>{apply()});cn();
setTimeout(()=>{apply()},100);
})();
</script>
</body>
</html>
+14
View File
@@ -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"
}
}
+17
View File
@@ -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
+10
View File
@@ -500,6 +500,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: move; cursor: move;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 12px; font-size: 12px;
@@ -507,7 +508,16 @@
background: var(--accent-container); background: var(--accent-container);
min-width: 80px; min-width: 80px;
min-height: 50px; 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 Page ===== */
.debug-container { .debug-container {
+202 -156
View File
@@ -3,7 +3,14 @@ const PageScene = {
_currentGameId: null, _currentGameId: null,
_currentGameName: '', _currentGameName: '',
_editorSceneId: null, _editorSceneId: null,
_editorData: null, _editorCanvasIdx: 0,
_placements: [],
_dashboards: [],
_dragItem: null,
_dragOffsetX: 0,
_dragOffsetY: 0,
_resizeItem: null,
_resizeDir: '',
init() { init() {
this._el = document.getElementById('page-scene'); this._el = document.getElementById('page-scene');
@@ -12,208 +19,247 @@ const PageScene = {
async render() { async render() {
const cfg = await API.getConfig(); const cfg = await API.getConfig();
this._currentGameId = cfg.selected_game_id; this._currentGameId = cfg.selected_game_id;
if (this._currentGameId) { if (this._currentGameId) {
const games = await API.getGames(); const games = await API.getGames();
const game = games.find(g => g.id === this._currentGameId); const game = games.find(g => g.id === this._currentGameId);
this._currentGameName = game ? game.name : this._currentGameId; this._currentGameName = game ? game.name : this._currentGameId;
this._dashboards = await API.getDashboards('all');
} }
this._el.innerHTML = this._template(); this._el.innerHTML = this._template();
await this._loadScenes(); this._showSceneList();
this._bindEvents(); 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'); const grid = document.getElementById('scene-grid');
if (!grid) return; if (!grid) return;
try { try {
const scenes = await API.getScenes(this._currentGameId); const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) { if (!scenes || scenes.length === 0) {
grid.innerHTML = ` grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>暂无场景</h4><p>点击"新建场景"创建你的场景</p></div>`;
<div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<h4>暂无场景</h4>
<p>点击"新建场景"创建你的第一个场景布局</p>
</div>`;
return; return;
} }
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join(''); grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) { } catch (e) { grid.innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
console.error('Failed to load scenes:', e);
}
}, },
_bindEvents() { async _openEditor(sceneId) {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor()); this._editorSceneId = sceneId;
document.getElementById('btn-import-scene')?.addEventListener('click', () => { this._editorCanvasIdx = 0;
const input = document.createElement('input'); this._placements = [];
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) {
let scene = null; let scene = null;
if (sceneId) { if (sceneId) {
scene = await API.getScene(sceneId); scene = await API.getScene(sceneId);
if (!scene) return; if (scene && scene.canvases && scene.canvases[0]) {
} this._placements = scene.canvases[0].placements || [];
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);
} }
modal.remove(); }
Toast.show('场景保存成功', 'success'); const listC = document.getElementById('scene-list-container');
this.render(); 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 =>
`<div class="glass-card editor-dash-item" draggable="true" data-dash-id="${d.id}" style="padding:10px;margin-bottom:6px;cursor:grab;font-size:13px;display:flex;align-items:center;gap:8px">
<span>${d.icon||'📊'}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
</div>`
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">无兼容仪表盘</p>';
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 `<div class="editor-placement" data-idx="${idx}" style="left:${p.x*scale}px;top:${p.y*scale}px;width:${p.width*scale}px;height:${p.height*scale}px;z-index:${p.z_index||0}">
<div class="ep-icon">${icon}</div>
<div class="ep-name">${name}</div>
<div class="ep-actions">
<button class="btn btn-sm btn-danger" data-action="remove-place" data-idx="${idx}">×</button>
</div>
</div>`;
}).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}`; const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank'); window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {});
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');
}, },
_sceneCardHtml(scene) { _sceneCardHtml(scene) {
const canvasCount = (scene.canvases || []).length; const canvasCount = (scene.canvases || []).length;
return ` return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
<div class="scene-card-name">${scene.name}</div> <div class="scene-card-name">${scene.name}</div>
<div class="scene-card-game" style="margin-bottom:6px;">${this._currentGameName || '未关联游戏'}</div> <div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || '未关联游戏'}</div>
<div class="scene-card-meta"> <div class="scene-card-meta"><span>${canvasCount} 画布</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
<span>${canvasCount} 个画布</span> <div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
<span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span> <div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
</div>
<div class="scene-canvas-list">
${(scene.canvases || []).map(c => `<span class="scene-canvas-badge">${c.label || c.width + 'x' + c.height}</span>`).join('')}
</div>
<div style="margin-top:12px;display:flex;gap:6px;">
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button> <button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">渲染</button>
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button> <button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">编辑</button>
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button> <button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button>
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button> <button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">删除</button>
</div> </div></div>`;
</div>`;
},
_editorTemplate(scene) {
const name = scene ? scene.name : '';
const desc = scene ? scene.description : '';
return `
<div class="modal">
<h3>${scene ? '编辑场景' : '新建场景'}</h3>
<div class="form-group">
<label>场景名称</label>
<input id="edit-scene-name" type="text" value="${name}" placeholder="输入场景名称">
</div>
<div class="form-group">
<label>描述</label>
<textarea id="edit-scene-desc" placeholder="场景描述(可选)">${desc}</textarea>
</div>
<p style="font-size:12px;color:var(--text-tertiary);margin-top:8px;">
提示:保存后可在场景编辑器中添加仪表盘、调整布局和画布比例。
</p>
<div class="modal-actions">
<button class="btn btn-secondary">取消</button>
<button class="btn btn-primary">保存</button>
</div>
</div>`;
}, },
_template() { _template() {
return ` return `
<div class="scene-header animated"> <div class="scene-header animated" id="scene-list-container">
<div> <div>
<h2>场景管理</h2> <h2>场景管理</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px;"> <p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}</p>
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px">
<button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button> <button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button>
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}> <button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>+ 新建场景</button>
+ 新建场景
</button>
</div> </div>
</div> </div>
<div id="scene-grid" class="scene-grid animated"></div>`; <div id="scene-grid" class="scene-grid animated"></div>
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:12px">
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">← 返回</button>
<input id="edit-scene-name" placeholder="场景名称" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
</div>
<button id="btn-save-scene" class="btn btn-primary btn-sm">保存场景</button>
</div>
<div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px">
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">仪表盘列表 (拖到画布)</h4>
<div id="editor-dash-list"></div>
</div>
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
<div id="editor-canvas" style="position:relative;background:rgba(255,255,255,.02);margin:0 auto;overflow:hidden;transition:width 0.2s,height 0.2s"></div>
</div>
</div>
</div>`;
} }
}; };