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>