refactor: standalone dashboard folders with index.html, zip-based import/export (.tsd/.tss/.tsp)

- Each dashboard is now a self-contained folder: manifest.json + index.html
- Auto-scan dashboards/ and data/dashboards/ on startup
- Export: .tsd (dashboards), .tss (scenes), .tsp (game plugins) - all zip format
- Dashboard index.html is complete standalone page (WS + data binding)
- Aspect ratio constraints handled in each dashboard's own JS
- Removed template-based dashboard rendering in favor of static serve
- Import via file upload endpoints, export via direct file download
This commit is contained in:
2026-07-25 15:35:08 +08:00
parent d61bb61a83
commit 63e3c8d607
21 changed files with 752 additions and 380 deletions
+10 -6
View File
@@ -57,13 +57,17 @@ async def index(request: Request):
@app.get("/dashboard/{theme_id}", response_class=HTMLResponse) @app.get("/dashboard/{theme_id}", response_class=HTMLResponse)
async def dashboard_render(request: Request, theme_id: str): async def dashboard_render(request: Request, theme_id: str):
from models.dashboard import dashboard_manager from models.dashboard import dashboard_manager
index_html = dashboard_manager.get_index_html(theme_id)
if index_html:
return HTMLResponse(content=index_html)
theme = dashboard_manager.get(theme_id) theme = dashboard_manager.get(theme_id)
template_html = dashboard_manager.get_template(theme_id) if not theme:
return templates.TemplateResponse("dashboard.html", { raise HTTPException(status_code=404, detail="Dashboard not found")
"request": request, return HTMLResponse(content=f"""
"theme": theme, <!DOCTYPE html><html><body style="background:#000;color:#888;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;">
"template_html": template_html, 仪表盘 "{theme['name']}" 缺少 index.html
}) </body></html>
""")
@app.get("/scene/{scene_id}", response_class=HTMLResponse) @app.get("/scene/{scene_id}", response_class=HTMLResponse)
+77
View File
@@ -0,0 +1,77 @@
<!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 - 档位</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif}
#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}
#box.center{width:auto;height:auto}
#content{width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:3vw}
.gear{width:min(30vw,140px);height:min(30vw,140px);border-radius:50%;background:rgba(52,130,255,.12);border:3px solid rgba(52,130,255,.45);display:flex;align-items:center;justify-content:center;box-shadow:0 0 40px rgba(52,130,255,.15)}
.gear span{font-size:min(14vw,64px);font-weight:900;color:#fff;transition:transform .15s ease}
.gear.flash span{color:#FF9F0A;transform:scale(1.1)}
.bars{display:flex;flex-direction:column;gap:4px}
.bar-item{display:flex;align-items:center;gap:6px}
.bar-item .label{font-size:min(2.5vw,13px);color:rgba(255,255,255,.35);width:min(5vw,20px);text-align:right}
.bar-item .fill{height:6px;background:rgba(255,255,255,.08);border-radius:3px;width:min(20vw,100px)}
.bar-item .fill-inner{height:100%;border-radius:3px;transition:background .3s ease}
.bar-item.active .label{color:#fff}
.bar-item.active .fill-inner{background:var(--accent,#3482FF)}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div class="gear" id="gear-el"><span id="gear-txt">N</span></div>
<div class="bars">
<div class="bar-item" data-g="1"><span class="label">1</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="2"><span class="label">2</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="3"><span class="label">3</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="4"><span class="label">4</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="5"><span class="label">5</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="6"><span class="label">6</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="7"><span class="label">7</span><div class="fill"><div class="fill-inner"></div></div></div>
<div class="bar-item" data-g="8"><span class="label">8</span><div class="fill"><div class="fill-inner"></div></div></div>
</div>
</div></div></div>
<script>
const M={aspect_ratio:"1:1",render_mode:"contain"};
(function(){
const box=document.getElementById('box'),gearTxt=document.getElementById('gear-txt'),gearEl=document.getElementById('gear-el');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt,lastGear=-1;
function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}return null;}
function apply(){
const ratio=parseRatio(M.aspect_ratio||'1:1'),mode=M.render_mode||'contain';
if(!ratio){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;
switch(mode){
case'cover':if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover';break;
case'fill':box.style.width='100vw';box.style.height='100vh';box.className='fill';return;
case'center':cw=Math.min(vw,800);ch=cw/ratio;if(ch>vh){ch=Math.min(vh,800);cw=ch*ratio};box.className='center';break;
default:if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio};box.className='contain';
}
if(mode!=='fill'){box.style.width=cw+'px';box.style.height=ch+'px';}
}
function connect(){
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'){const g=m.data.gear||0;if(g!==lastGear){lastGear=g;gearEl.classList.add('flash');setTimeout(()=>gearEl.classList.remove('flash'),200)}
gearTxt.textContent=g===0?'N':g<0?'R':String(g);
document.querySelectorAll('.bar-item').forEach(el=>{el.classList.toggle('active',parseInt(el.dataset.g)===g)})}}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)};
}
apply();window.addEventListener('resize',apply);connect();
})();
</script>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
<div style="display:flex;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:20px;">
<div style="width:100px;height:100px;border-radius:50%;background:rgba(102,126,234,0.15);border:3px solid rgba(102,126,234,0.5);display:flex;align-items:center;justify-content:center;box-shadow:0 0 30px rgba(102,126,234,0.2);">
<span style="font-size:56px;font-weight:900;color:#fff;" data-bind="gear">N</span>
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">R</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">1</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">2</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">3</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">4</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">5</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:rgba(255,255,255,0.5);width:24px;">6</span>
<div style="width:80px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;"></div>
</div>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
<!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 - 圈速</title>
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif;color:#fff}
#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}
#box.center{width:auto;height:auto}
#content{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:min(3vw,16px)}
.lap-num{font-size:min(3vw,16px);color:rgba(255,255,255,.3);letter-spacing:.15em;margin-bottom:-8px}
.current{font-size:min(12vw,72px);font-weight:900;color:#3482FF;font-variant-numeric:tabular-nums}
.below{display:flex;gap:min(8vw,48px)}
.col{text-align:center}
.col .label{font-size:min(2vw,12px);color:rgba(255,255,255,.35);letter-spacing:.08em;margin-bottom:2px}
.col .val{font-size:min(5vw,28px);font-weight:700;font-variant-numeric:tabular-nums}
.col:last-child .val{color:#34C759}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div class="lap-num">LAP <span id="lap-n">0</span></div>
<div class="current" id="cur">00:00.000</div>
<div class="below">
<div class="col"><div class="label">LAST</div><div class="val" id="last" style="color:rgba(255,255,255,.55)">00:00.000</div></div>
<div class="col"><div class="label">BEST</div><div class="val" id="best">00:00.000</div></div>
</div>
</div></div></div>
<script>
const M={aspect_ratio:"4:3",render_mode:"contain"};
(function(){
const box=document.getElementById('box'),curEl=document.getElementById('cur'),lastEl=document.getElementById('last'),bestEl=document.getElementById('best'),lapN=document.getElementById('lap-n');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt;
function fmt(t){if(!t||t<=0)return'00:00.000';const m=Math.floor(t/60),s=(t%60).toFixed(3);return String(m).padStart(2,'0')+':'+(m>0?String(s).padStart(6,'0'):s)}
function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}return null;}
function apply(){
const ratio=parseRatio(M.aspect_ratio||'4:3'),mode=M.render_mode||'contain';
if(!ratio){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;
switch(mode){
case'cover':if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover';break;
case'fill':box.style.width='100vw';box.style.height='100vh';box.className='fill';return;
case'center':cw=Math.min(vw,1200);ch=cw/ratio;if(ch>vh){ch=Math.min(vh,900);cw=ch*ratio};box.className='center';break;
default:if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio};box.className='contain';
}
if(mode!=='fill'){box.style.width=cw+'px';box.style.height=ch+'px';}
}
function connect(){
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'){const d=m.data;curEl.textContent=fmt(d.lap_time);lastEl.textContent=fmt(d.last_lap);bestEl.textContent=fmt(d.best_lap);lapN.textContent=d.lap_number||0}}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)};
}
apply();window.addEventListener('resize',apply);connect();
})();
</script>
</body>
</html>
-20
View File
@@ -1,20 +0,0 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);gap:16px;">
<div style="text-align:center;">
<div style="font-size:12px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">LAP</div>
<div style="font-size:14px;color:rgba(255,255,255,0.3);" data-bind="lap_number">0</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:2px;margin-bottom:4px;">CURRENT</div>
<div style="font-size:48px;font-weight:900;color:#667eea;font-variant-numeric:tabular-nums;" data-bind="lap_time">00:00.000</div>
</div>
<div style="display:flex;gap:40px;">
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">LAST</div>
<div style="font-size:24px;font-weight:700;color:rgba(255,255,255,0.6);font-variant-numeric:tabular-nums;" data-bind="last_lap">00:00.000</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px;color:rgba(255,255,255,0.4);letter-spacing:1px;margin-bottom:4px;">BEST</div>
<div style="font-size:24px;font-weight:700;color:#2ecc71;font-variant-numeric:tabular-nums;" data-bind="best_lap">00:00.000</div>
</div>
</div>
</div>
+75
View File
@@ -0,0 +1,75 @@
<!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 - 速度表</title>
<style>
:root{--ratio:auto;--mode:contain}
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;color:#fff;font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif;-webkit-tap-highlight-color:transparent}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#content{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a14,#1a1a30)}
.speed{font-size:min(18vw,160px);font-weight:900;line-height:1;color:#fff;text-shadow:0 0 50px rgba(52,130,255,.5);transition:font-size .1s ease}
.unit{font-size:min(3vw,28px);color:rgba(255,255,255,.55);margin-top:8px;letter-spacing:.3em}
.bar{width:min(80%,400px);height:6px;background:rgba(255,255,255,.1);border-radius:3px;overflow:hidden;margin-top:24px}
.bar-fill{height:100%;background:linear-gradient(90deg,#3482FF,#764ba2);border-radius:3px;transition:width .08s linear}
/* aspect-ratio variants */
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
#box.center{width:auto;height:auto}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<div class="speed" id="v">0</div>
<div class="unit">KM/H</div>
<div class="bar"><div class="bar-fill" id="bar"></div></div>
</div></div></div>
<script>
const M={aspect_ratio:"auto",render_mode:"contain"};
(function(){
const box=document.getElementById('box');
const vEl=document.getElementById('v');
const bar=document.getElementById('bar');
const proto=location.protocol==='https:'?'wss:':'ws:';
const wsUrl=proto+'//'+location.host+'/ws';
let ws,rt;
function parseRatio(r){
if(!r||r==='auto')return null;
const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}
return null;
}
function apply(){
const ratio=parseRatio(M.aspect_ratio||'auto');
const mode=M.render_mode||'contain';
if(!ratio){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;
switch(mode){
case'cover':if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover';break;
case'fill':box.style.width='100vw';box.style.height='100vh';box.className='fill';return;
case'center':cw=Math.min(vw,1920);ch=cw/ratio;if(ch>vh){ch=Math.min(vh,1080);cw=ch*ratio};box.className='center';break;
default:if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio};box.className='contain';
}
if(mode!=='fill'){box.style.width=cw+'px';box.style.height=ch+'px';}
}
function connect(){
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'){const d=m.data;vEl.textContent=(d.speed_kmh||0).toFixed(0);bar.style.width=Math.min((d.speed_kmh||0)/400,1)*100+'%'}}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)};
}
apply();
window.addEventListener('resize',apply);
connect();
})();
</script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14 0%,#1a1a30 100%);">
<div style="font-size:120px;font-weight:900;line-height:1;color:#fff;text-shadow:0 0 40px rgba(102,126,234,0.5);" data-bind="speed_kmh">0</div>
<div style="font-size:24px;color:rgba(255,255,255,0.6);margin-top:8px;letter-spacing:4px;">KM/H</div>
<div style="margin-top:20px;width:300px;height:6px;background:rgba(255,255,255,0.1);border-radius:3px;overflow:hidden;">
<div data-bind-speed style="height:100%;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:3px;transition:width 0.1s ease;width:calc(var(--speed,0) / 400 * 100%);"></div>
</div>
</div>
+72
View File
@@ -0,0 +1,72 @@
<!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 - 转速表</title>
<style>
:root{--ratio:16/9;--mode:contain}
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:'SF Pro Text',-apple-system,'PingFang SC',sans-serif}
#root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
#box{position:relative;overflow:hidden}
#content{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:linear-gradient(135deg,#0a0a14,#1a1a30)}
svg{width:min(90%,500px)}
.rpm-text{font-size:min(8vw,48px);font-weight:900;fill:#fff}
.rpm-label{font-size:min(2vw,14px);fill:rgba(255,255,255,.45)}
.gauge-bg{fill:none;stroke:rgba(255,255,255,.08);stroke-width:20;stroke-linecap:round}
.gauge-fg{fill:none;stroke:url(#g);stroke-width:20;stroke-linecap:round;stroke-dasharray:314;transition:stroke-dashoffset .1s ease}
.labels text{font-size:10px;fill:rgba(255,255,255,.25);text-anchor:middle}
/* aspect-ratio */
#box.contain{max-width:100vw;max-height:100vh}
#box.cover{min-width:100vw;min-height:100vh}
#box.fill{width:100vw;height:100vh}
#box.center{width:auto;height:auto}
</style>
</head>
<body>
<div id="root"><div id="box" class="contain"><div id="content">
<svg viewBox="0 0 320 200">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="#3482FF"/><stop offset="40%" stop-color="#3482FF"/><stop offset="70%" stop-color="#FF9F0A"/><stop offset="100%" stop-color="#E94634"/></linearGradient></defs>
<path d="M 30,170 A 130,130 0 0,1 290,170" class="gauge-bg"/>
<path id="arc" d="M 30,170 A 130,130 0 0,1 290,170" class="gauge-fg" stroke-dashoffset="314"/>
<text x="160" y="148" text-anchor="middle" class="rpm-text" id="rpm-v">0</text>
<text x="160" y="172" text-anchor="middle" class="rpm-label">RPM</text>
<g class="labels">
<text x="28" y="188">0</text><text x="80" y="72">2k</text><text x="160" y="44">4k</text><text x="240" y="72">6k</text><text x="292" y="188">8k</text>
</g>
</svg>
</div></div></div>
<script>
const M={aspect_ratio:"16:9",render_mode:"contain"};
(function(){
const box=document.getElementById('box'),arc=document.getElementById('arc'),rpmV=document.getElementById('rpm-v');
const proto=location.protocol==='https:'?'wss:':'ws:',wsUrl=proto+'//'+location.host+'/ws';
let ws,rt;
function parseRatio(r){if(!r||r==='auto')return null;const p=r.split(':');if(p.length===2){const w=+p[0],h=+p[1];if(w>0&&h>0)return w/h;}return null;}
function apply(){
const ratio=parseRatio(M.aspect_ratio||'16:9'),mode=M.render_mode||'contain';
if(!ratio){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;
switch(mode){
case'cover':if(vr>ratio){cw=vw;ch=vw/ratio}else{ch=vh;cw=vh*ratio};box.className='cover';break;
case'fill':box.style.width='100vw';box.style.height='100vh';box.className='fill';return;
case'center':cw=Math.min(vw,1920);ch=cw/ratio;if(ch>vh){ch=Math.min(vh,1080);cw=ch*ratio};box.className='center';break;
default:if(vr>ratio){ch=vh;cw=vh*ratio}else{cw=vw;ch=vw/ratio};box.className='contain';
}
if(mode!=='fill'){box.style.width=cw+'px';box.style.height=ch+'px';}
}
function connect(){
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'){const d=m.data,rpm=d.rpm||0,max=d.max_rpm||8000,pct=Math.min(rpm/max,1);rpmV.textContent=rpm.toFixed(0);arc.setAttribute('stroke-dashoffset',314-314*pct)}}catch(e){}};
ws.onclose=()=>{rt=setTimeout(connect,2000)};
}
apply();window.addEventListener('resize',apply);connect();
})();
</script>
</body>
</html>
-35
View File
@@ -1,35 +0,0 @@
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;font-family:'Segoe UI',system-ui,sans-serif;background:linear-gradient(135deg,#0a0a14,#1a1a30);">
<svg viewBox="0 0 300 200" width="90%" style="max-width:500px;">
<defs>
<linearGradient id="rpmGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="40%" stop-color="#667eea"/>
<stop offset="70%" stop-color="#f39c12"/>
<stop offset="100%" stop-color="#e74c3c"/>
</linearGradient>
</defs>
<path d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="18" stroke-linecap="round"/>
<path id="rpm-arc" d="M 50,170 A 100,100 0 0,1 250,170" fill="none" stroke="url(#rpmGrad)" stroke-width="18" stroke-linecap="round"
stroke-dasharray="314" stroke-dashoffset="314" style="transition:stroke-dashoffset 0.1s ease;"/>
<text x="150" y="150" text-anchor="middle" fill="#fff" font-size="48" font-weight="900" data-bind="rpm">0</text>
<text x="150" y="180" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="14">RPM</text>
<text x="42" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">0</text>
<text x="258" y="185" text-anchor="middle" fill="rgba(255,255,255,0.3)" font-size="10">8k</text>
</svg>
<div data-bind-rpm style="display:none;">
<script>
(function() {
const arc = document.getElementById('rpm-arc');
const observer = new MutationObserver(() => {
const rpm = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm')) || 0;
const max = parseFloat(getComputedStyle(document.querySelector('[data-bind-rpm]')).getPropertyValue('--rpm-max')) || 8000;
if (!isNaN(rpm) && arc) {
const pct = Math.min(rpm / max, 1);
arc.setAttribute('stroke-dashoffset', 314 - (314 * pct));
}
});
observer.observe(document.querySelector('[data-bind-rpm]'), { attributes: true, attributeFilter: ['style'] });
})();
</script>
</div>
</div>
+175 -109
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import os
import shutil import shutil
import uuid import uuid
import zipfile
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,87 +15,142 @@ from utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
DATA_DIR = Path(__file__).resolve().parent.parent / "data" DATA_DIR = Path(__file__).resolve().parent.parent / "data"
DASHBOARDS_DIR = DATA_DIR / "dashboards" USER_DASHBOARDS_DIR = DATA_DIR / "dashboards"
BUILTIN_DASHBOARDS_DIR = Path(__file__).resolve().parent.parent / "dashboards" BUILTIN_DASHBOARDS_DIR = Path(__file__).resolve().parent.parent / "dashboards"
@dataclass @dataclass
class DashboardTheme: class DashboardTheme:
id: str = field(default_factory=lambda: str(uuid.uuid4())) id: str = ""
name: str = "" name: str = ""
category: str = "basic" category: str = "basic"
description: str = "" description: str = ""
author: str = "" author: str = ""
version: str = "1.0.0" version: str = "1.0.0"
preview: str = "" icon: str = "📊"
config: dict[str, Any] = field(default_factory=dict) aspect_ratio: str = "auto"
created_at: str = field(default_factory=lambda: datetime.now().isoformat()) render_mode: str = "contain"
updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) created_at: str = ""
updated_at: str = ""
is_builtin: bool = False is_builtin: bool = False
dir_path: str = ""
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return asdict(self) return {
"id": self.id,
"name": self.name,
"category": self.category,
"description": self.description,
"author": self.author,
"version": self.version,
"icon": self.icon,
"aspect_ratio": self.aspect_ratio,
"render_mode": self.render_mode,
"created_at": self.created_at,
"updated_at": self.updated_at,
"is_builtin": self.is_builtin,
}
@classmethod @classmethod
def from_dict(cls, d: dict[str, Any]) -> DashboardTheme: def from_manifest(cls, manifest_path: Path, is_builtin: bool = False) -> DashboardTheme | None:
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) try:
with open(manifest_path, "r", encoding="utf-8") as f:
data = json.load(f)
config = data.get("config", {})
return cls(
id=data.get("id", manifest_path.parent.name),
name=data.get("name", manifest_path.parent.name),
category=data.get("category", "basic"),
description=data.get("description", ""),
author=data.get("author", ""),
version=data.get("version", "1.0.0"),
icon=config.get("icon", "📊"),
aspect_ratio=config.get("aspect_ratio", "auto"),
render_mode=config.get("render_mode", "contain"),
created_at=data.get("created_at", ""),
updated_at=data.get("updated_at", ""),
is_builtin=is_builtin,
dir_path=str(manifest_path.parent),
)
except Exception as e:
logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e)
return None
class DashboardManager: class DashboardManager:
def __init__(self): def __init__(self):
DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True) USER_DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, DashboardTheme] = {}
self._load_builtins()
def _load_builtins(self): def _scan_dirs(self, base_dir: Path, is_builtin: bool) -> list[DashboardTheme]:
if not BUILTIN_DASHBOARDS_DIR.exists(): themes = []
return if not base_dir.exists():
for item in BUILTIN_DASHBOARDS_DIR.iterdir(): return themes
if item.is_dir(): for item in sorted(base_dir.iterdir()):
cfg_file = item / "config.json" if not item.is_dir():
if cfg_file.exists(): continue
try: manifest = item / "manifest.json"
with open(cfg_file, "r", encoding="utf-8") as f: if manifest.exists():
data = json.load(f) theme = DashboardTheme.from_manifest(manifest, is_builtin)
theme = DashboardTheme.from_dict(data) if theme:
theme.is_builtin = True themes.append(theme)
self._cache[theme.id] = theme return themes
except Exception as e:
logger.error("Failed to load builtin dashboard %s: %s", item.name, e)
def _load_user_dashboards(self):
for f in DASHBOARDS_DIR.glob("*.json"):
try:
with open(f, "r", encoding="utf-8") as fp:
data = json.load(fp)
theme = DashboardTheme.from_dict(data)
self._cache[theme.id] = theme
except Exception as e:
logger.error("Failed to load dashboard %s: %s", f.name, e)
def list_all(self, category: str | None = None) -> list[dict[str, Any]]: def list_all(self, category: str | None = None) -> list[dict[str, Any]]:
self._cache.clear() themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True)
self._load_builtins() themes += self._scan_dirs(USER_DASHBOARDS_DIR, False)
self._load_user_dashboards() result = [t.to_dict() for t in themes]
result = [t.to_dict() for t in self._cache.values()]
if category and category != "all": if category and category != "all":
result = [r for r in result if r.get("category") == category] result = [r for r in result if r.get("category") == category]
return result return result
def get(self, theme_id: str) -> dict[str, Any] | None: def get(self, theme_id: str) -> dict[str, Any] | None:
self._cache.clear() for base, is_builtin in [(BUILTIN_DASHBOARDS_DIR, True), (USER_DASHBOARDS_DIR, False)]:
self._load_builtins() theme_dir = base / theme_id
self._load_user_dashboards() manifest = theme_dir / "manifest.json"
theme = self._cache.get(theme_id) if manifest.exists():
return theme.to_dict() if theme else None theme = DashboardTheme.from_manifest(manifest, is_builtin)
if theme:
return theme.to_dict()
return None
def save(self, theme: DashboardTheme) -> bool: def get_dir(self, theme_id: str) -> Path | None:
theme.updated_at = datetime.now().isoformat() for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
theme.is_builtin = False theme_dir = base / theme_id
filepath = DASHBOARDS_DIR / f"{theme.id}.json" if (theme_dir / "manifest.json").exists():
return theme_dir
return None
def get_index_html(self, theme_id: str) -> str | None:
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
idx = base / theme_id / "index.html"
if idx.exists():
return idx.read_text(encoding="utf-8")
return None
def save(self, theme: DashboardTheme, index_html: str = "") -> bool:
theme_dir = USER_DASHBOARDS_DIR / theme.id
theme_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"id": theme.id,
"name": theme.name,
"category": theme.category,
"description": theme.description,
"author": theme.author,
"version": theme.version,
"config": {
"icon": theme.icon,
"aspect_ratio": theme.aspect_ratio,
"render_mode": theme.render_mode,
},
"created_at": theme.created_at or datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
try: try:
with open(filepath, "w", encoding="utf-8") as f: with open(theme_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(theme.to_dict(), f, indent=2, ensure_ascii=False) json.dump(manifest, f, indent=2, ensure_ascii=False)
if index_html:
with open(theme_dir / "index.html", "w", encoding="utf-8") as f:
f.write(index_html)
logger.info("Dashboard saved: %s", theme.id) logger.info("Dashboard saved: %s", theme.id)
return True return True
except Exception as e: except Exception as e:
@@ -102,69 +158,79 @@ class DashboardManager:
return False return False
def delete(self, theme_id: str) -> bool: def delete(self, theme_id: str) -> bool:
self._cache.clear() theme_dir = USER_DASHBOARDS_DIR / theme_id
self._load_builtins() if theme_dir.exists():
self._load_user_dashboards() shutil.rmtree(theme_dir)
theme = self._cache.get(theme_id)
if theme and theme.is_builtin:
logger.warning("Cannot delete builtin dashboard: %s", theme_id)
return False
filepath = DASHBOARDS_DIR / f"{theme_id}.json"
if filepath.exists():
filepath.unlink()
logger.info("Dashboard deleted: %s", theme_id) logger.info("Dashboard deleted: %s", theme_id)
return True return True
return False
def export_theme(self, theme_id: str) -> dict[str, Any] | None: def export_theme_zip(self, theme_id: str) -> bytes | None:
theme = self.get(theme_id) theme_dir = self.get_dir(theme_id)
if not theme: if not theme_dir:
return None return None
result = { buf = io.BytesIO()
"type": "dashboard_theme", with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
"version": "1.0", for f in sorted(theme_dir.rglob('*')):
"data": theme, if f.is_file():
"html": self._read_template_file(theme["id"]), arcname = str(f.relative_to(theme_dir))
} zf.write(f, arcname)
return result logger.info("Dashboard exported as zip: %s (%d bytes)", theme_id, buf.tell())
return buf.getvalue()
def import_theme(self, data: dict[str, Any]) -> bool: def import_theme_zip(self, zip_data: bytes) -> bool:
if data.get("type") != "dashboard_theme": try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
manifest_name = None
for n in names:
if n.endswith('manifest.json'):
manifest_name = n
break
if not manifest_name:
logger.error("No manifest.json found in zip")
return False
manifest_data = json.loads(zf.read(manifest_name).decode('utf-8'))
theme_id = manifest_data.get('id', str(uuid.uuid4()))
existing_dir = self.get_dir(theme_id)
if existing_dir and existing_dir.parent == BUILTIN_DASHBOARDS_DIR:
theme_id = str(uuid.uuid4())
manifest_data['id'] = theme_id
dest = USER_DASHBOARDS_DIR / theme_id
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True)
prefix = manifest_name.rsplit('manifest.json', 1)[0]
for name in names:
if name == manifest_name:
continue
rel = name
if prefix and name.startswith(prefix):
rel = name[len(prefix):]
if not rel or rel.endswith('/'):
continue
out_path = dest / rel
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(zf.read(name))
with open(dest / 'manifest.json', 'w', encoding='utf-8') as f:
json.dump(manifest_data, f, indent=2, ensure_ascii=False)
logger.info("Dashboard imported from zip: %s", theme_id)
return True
except Exception as e:
logger.error("Failed to import dashboard zip: %s", e)
return False return False
theme_data = data.get("data", {})
theme = DashboardTheme.from_dict(theme_data)
html_content = data.get("html", "")
if theme.id in self._cache:
theme.id = str(uuid.uuid4())
success = self.save(theme)
if success and html_content:
self._save_template_file(theme.id, html_content)
return success
def _read_template_file(self, theme_id: str) -> str:
for base in [DASHBOARDS_DIR, BUILTIN_DASHBOARDS_DIR]:
tmpl = base / theme_id / "template.html"
if tmpl.exists():
return tmpl.read_text(encoding="utf-8")
return ""
def _save_template_file(self, theme_id: str, content: str):
theme_dir = DASHBOARDS_DIR / theme_id
theme_dir.mkdir(parents=True, exist_ok=True)
tmpl = theme_dir / "template.html"
tmpl.write_text(content, encoding="utf-8")
def get_template(self, theme_id: str) -> str:
return self._read_template_file(theme_id)
def get_categories(self) -> list[str]: def get_categories(self) -> list[str]:
self._cache.clear() themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True)
self._load_builtins() themes += self._scan_dirs(USER_DASHBOARDS_DIR, False)
self._load_user_dashboards() cats = sorted(set(t.category for t in themes if t.category))
cats = set() return cats
for t in self._cache.values():
if t.category:
cats.add(t.category)
return sorted(cats)
dashboard_manager = DashboardManager() dashboard_manager = DashboardManager()
+22 -15
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import shutil
import uuid import uuid
import zipfile
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -124,24 +127,28 @@ class SceneManager:
return True return True
return False return False
def export_scene(self, scene_id: str) -> dict[str, Any] | None: def export_scene_zip(self, scene_id: str) -> bytes | None:
scene = self.get(scene_id) filepath = SCENES_DIR / f"{scene_id}.json"
if not scene: if not filepath.exists():
return None return None
return { buf = io.BytesIO()
"type": "scene", with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
"version": "1.0", zf.write(filepath, "scene.json")
"data": scene, logger.info("Scene exported as zip: %s", scene_id)
} return buf.getvalue()
def import_scene(self, data: dict[str, Any]) -> bool: def import_scene_zip(self, zip_data: bytes) -> bool:
if data.get("type") != "scene": try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
if "scene.json" not in zf.namelist():
logger.error("No scene.json found in zip")
return False
data = json.loads(zf.read("scene.json").decode('utf-8'))
scene = Scene.from_dict(data)
return self.save(scene)
except Exception as e:
logger.error("Failed to import scene zip: %s", e)
return False return False
scene_data = data.get("data", {})
if "id" not in scene_data:
scene_data["id"] = str(uuid.uuid4())
scene = Scene.from_dict(scene_data)
return self.save(scene)
scene_manager = SceneManager() scene_manager = SceneManager()
+61 -28
View File
@@ -103,22 +103,23 @@ async def api_get_dashboard(theme_id: str):
@router.get("/dashboards/{theme_id}/template") @router.get("/dashboards/{theme_id}/template")
async def api_get_dashboard_template(theme_id: str): async def api_get_dashboard_template(theme_id: str):
tmpl = dashboard_manager.get_template(theme_id) tmpl = dashboard_manager.get_index_html(theme_id)
return {"html": tmpl} return {"html": tmpl or ""}
@router.post("/dashboards") @router.post("/dashboards")
async def api_create_dashboard(data: dict[str, Any]): async def api_create_dashboard(data: dict[str, Any]):
cfg = data.get("config", {})
theme = DashboardTheme( theme = DashboardTheme(
name=data.get("name", "Untitled"), name=data.get("name", "Untitled"),
category=data.get("category", "basic"), category=data.get("category", "basic"),
description=data.get("description", ""), description=data.get("description", ""),
author=data.get("author", ""), author=data.get("author", ""),
config=data.get("config", {}), icon=cfg.get("icon", "📊"),
aspect_ratio=cfg.get("aspect_ratio", "auto"),
render_mode=cfg.get("render_mode", "contain"),
) )
dashboard_manager.save(theme) dashboard_manager.save(theme, data.get("index_html", ""))
if data.get("template_html"):
dashboard_manager._save_template_file(theme.id, data["template_html"])
return theme.to_dict() return theme.to_dict()
@@ -127,13 +128,17 @@ async def api_update_dashboard(theme_id: str, data: dict[str, Any]):
existing = dashboard_manager.get(theme_id) existing = dashboard_manager.get(theme_id)
if not existing: if not existing:
raise HTTPException(404, "Dashboard not found") raise HTTPException(404, "Dashboard not found")
theme = DashboardTheme.from_dict(existing) theme = DashboardTheme(
for k in ["name", "category", "description", "author", "config"]: id=theme_id,
if k in data: name=data.get("name", existing.get("name", "")),
setattr(theme, k, data[k]) category=data.get("category", existing.get("category", "basic")),
dashboard_manager.save(theme) description=data.get("description", existing.get("description", "")),
if "template_html" in data: author=data.get("author", existing.get("author", "")),
dashboard_manager._save_template_file(theme.id, data["template_html"]) icon=data.get("icon", existing.get("icon", "📊")),
aspect_ratio=data.get("aspect_ratio", existing.get("aspect_ratio", "auto")),
render_mode=data.get("render_mode", existing.get("render_mode", "contain")),
)
dashboard_manager.save(theme, data.get("index_html", ""))
return theme.to_dict() return theme.to_dict()
@@ -145,15 +150,25 @@ async def api_delete_dashboard(theme_id: str):
@router.get("/dashboards/{theme_id}/export") @router.get("/dashboards/{theme_id}/export")
async def api_export_dashboard(theme_id: str): async def api_export_dashboard(theme_id: str):
result = dashboard_manager.export_theme(theme_id) from fastapi.responses import Response
if not result: zip_bytes = dashboard_manager.export_theme_zip(theme_id)
if not zip_bytes:
raise HTTPException(404, "Dashboard not found") raise HTTPException(404, "Dashboard not found")
return result theme = dashboard_manager.get(theme_id)
filename = f"{theme['id']}.tsd" if theme else "dashboard.tsd"
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/dashboards/import") @router.post("/dashboards/import")
async def api_import_dashboard(data: dict[str, Any]): async def api_import_dashboard(file: UploadFile = File(...)):
ok = dashboard_manager.import_theme(data) if not file.filename or not file.filename.endswith('.tsd'):
raise HTTPException(400, "Only .tsd files are accepted")
zip_data = await file.read()
ok = dashboard_manager.import_theme_zip(zip_data)
return {"ok": ok} return {"ok": ok}
@@ -224,15 +239,25 @@ async def api_delete_scene(scene_id: str):
@router.get("/scenes/{scene_id}/export") @router.get("/scenes/{scene_id}/export")
async def api_export_scene(scene_id: str): async def api_export_scene(scene_id: str):
result = scene_manager.export_scene(scene_id) from fastapi.responses import Response
if not result: zip_bytes = scene_manager.export_scene_zip(scene_id)
if not zip_bytes:
raise HTTPException(404, "Scene not found") raise HTTPException(404, "Scene not found")
return result scene = scene_manager.get(scene_id)
filename = f"{scene['id']}.tss" if scene else "scene.tss"
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/scenes/import") @router.post("/scenes/import")
async def api_import_scene(data: dict[str, Any]): async def api_import_scene(file: UploadFile = File(...)):
ok = scene_manager.import_scene(data) if not file.filename or not file.filename.endswith('.tss'):
raise HTTPException(400, "Only .tss files are accepted")
zip_data = await file.read()
ok = scene_manager.import_scene_zip(zip_data)
return {"ok": ok} return {"ok": ok}
@@ -254,8 +279,11 @@ async def api_get_game(plugin_id: str):
@router.post("/games/install") @router.post("/games/install")
async def api_install_game_plugin(data: dict[str, Any]): async def api_install_game_plugin(file: UploadFile = File(...)):
ok = game_plugin_manager.install_plugin(data) if not file.filename or not file.filename.endswith('.tsp'):
raise HTTPException(400, "Only .tsp files are accepted")
zip_data = await file.read()
ok = game_plugin_manager.install_plugin_zip(zip_data)
return {"ok": ok} return {"ok": ok}
@@ -267,10 +295,15 @@ async def api_remove_game_plugin(plugin_id: str):
@router.get("/games/{plugin_id}/export") @router.get("/games/{plugin_id}/export")
async def api_export_game_plugin(plugin_id: str): async def api_export_game_plugin(plugin_id: str):
result = game_plugin_manager.export_plugin(plugin_id) from fastapi.responses import Response
if not result: zip_bytes = game_plugin_manager.export_plugin_zip(plugin_id)
if not zip_bytes:
raise HTTPException(404, "Game plugin not found") raise HTTPException(404, "Game plugin not found")
return result return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{plugin_id}.tsp"'},
)
@router.post("/games/reload") @router.post("/games/reload")
+52 -53
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import io
import json import json
import shutil
import zipfile
import importlib.util import importlib.util
import sys import sys
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
@@ -127,65 +130,61 @@ class GamePluginManager:
self._discover() self._discover()
self._parser_cache.clear() self._parser_cache.clear()
def install_plugin(self, data: dict[str, Any]) -> bool: def install_plugin_zip(self, zip_data: bytes) -> bool:
plugin_id = data.get("id", "") try:
if not plugin_id: with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
manifest_name = None
for n in names:
if n.endswith('manifest.json'):
manifest_name = n
break
if not manifest_name:
logger.error("No manifest.json found in plugin zip")
return False
manifest = json.loads(zf.read(manifest_name).decode('utf-8'))
plugin_id = manifest.get("id", "")
if not plugin_id:
return False
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True)
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
for name in names:
if name == manifest_name or name.endswith('/'):
continue
if name.endswith('parser.py'):
out_path = dest_dir / "parser.py"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(zf.read(name))
self._discover()
logger.info("Plugin installed from zip: %s", plugin_id)
return True
except Exception as e:
logger.error("Failed to install plugin zip: %s", e)
return False return False
dest_dir = USER_DIR / plugin_id def export_plugin_zip(self, plugin_id: str) -> bytes | None:
dest_dir.mkdir(parents=True, exist_ok=True)
manifest = data.get("manifest", {})
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
if data.get("parser_code"):
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
f.write(data["parser_code"])
self._discover()
logger.info("Plugin installed: %s", plugin_id)
return True
def remove_plugin(self, plugin_id: str) -> bool:
gp = self._plugins.get(plugin_id)
if gp and gp.is_builtin:
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
return False
import shutil
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
self._discover()
logger.info("Plugin removed: %s", plugin_id)
return True
return False
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
gp = self._plugins.get(plugin_id) gp = self._plugins.get(plugin_id)
if not gp: if not gp:
return None return None
parser_dir = Path(gp.manifest_path) parser_dir = Path(gp.manifest_path)
manifest_file = parser_dir / "manifest.json" if not parser_dir.exists():
parser_file = parser_dir / "parser.py" return None
buf = io.BytesIO()
result: dict[str, Any] = { with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
"type": "game_plugin", for f in sorted(parser_dir.rglob('*')):
"version": "1.0", if f.is_file():
"manifest": {}, zf.write(f, f.relative_to(parser_dir))
"parser_code": "", logger.info("Plugin exported as zip: %s", plugin_id)
} return buf.getvalue()
if manifest_file.exists():
with open(manifest_file, "r", encoding="utf-8") as f:
result["manifest"] = json.load(f)
if parser_file.exists():
with open(parser_file, "r", encoding="utf-8") as f:
result["parser_code"] = f.read()
return result
game_plugin_manager = GamePluginManager() game_plugin_manager = GamePluginManager()
-3
View File
@@ -43,7 +43,6 @@ const API = {
async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); }, async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); },
async deleteDashboard(id) { return this.del(`/dashboards/${id}`); }, async deleteDashboard(id) { return this.del(`/dashboards/${id}`); },
async exportDashboard(id) { return this.get(`/dashboards/${id}/export`); }, async exportDashboard(id) { return this.get(`/dashboards/${id}/export`); },
async importDashboard(data) { return this.post('/dashboards/import', data); },
async getScenes(gameId) { return this.get(`/scenes?game_id=${gameId || ''}`); }, async getScenes(gameId) { return this.get(`/scenes?game_id=${gameId || ''}`); },
async getScene(id) { return this.get(`/scenes/${id}`); }, async getScene(id) { return this.get(`/scenes/${id}`); },
@@ -51,13 +50,11 @@ const API = {
async updateScene(id, data) { return this.put(`/scenes/${id}`, data); }, async updateScene(id, data) { return this.put(`/scenes/${id}`, data); },
async deleteScene(id) { return this.del(`/scenes/${id}`); }, async deleteScene(id) { return this.del(`/scenes/${id}`); },
async exportScene(id) { return this.get(`/scenes/${id}/export`); }, async exportScene(id) { return this.get(`/scenes/${id}/export`); },
async importScene(data) { return this.post('/scenes/import', data); },
async startTelemetry() { return this.post('/telemetry/start'); }, async startTelemetry() { return this.post('/telemetry/start'); },
async stopTelemetry() { return this.post('/telemetry/stop'); }, async stopTelemetry() { return this.post('/telemetry/stop'); },
async getLatestTelemetry() { return this.get('/telemetry/latest'); }, async getLatestTelemetry() { return this.get('/telemetry/latest'); },
async installGamePlugin(data) { return this.post('/games/install', data); },
async removeGamePlugin(id) { return this.del(`/games/${id}`); }, async removeGamePlugin(id) { return this.del(`/games/${id}`); },
async exportGamePlugin(id) { return this.get(`/games/${id}/export`); }, async exportGamePlugin(id) { return this.get(`/games/${id}/export`); },
async reloadGamePlugins() { return this.post('/games/reload'); }, async reloadGamePlugins() { return this.post('/games/reload'); },
+39 -2
View File
@@ -83,7 +83,33 @@ const PageDashboard = {
const themeId = card.dataset.themeId; const themeId = card.dataset.themeId;
this._openDashboard(themeId); this._openDashboard(themeId);
}); });
gridEl.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.theme-card');
if (!card) return;
e.preventDefault();
const themeId = card.dataset.themeId;
this._exportDashboard(themeId);
});
} }
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.tsd';
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/dashboards/import', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
else Toast.show('导入失败', 'error');
} catch (err) { Toast.show('导入失败', 'error'); }
};
input.click();
});
}, },
_openDashboard(themeId) { _openDashboard(themeId) {
@@ -96,9 +122,17 @@ const PageDashboard = {
}); });
}, },
_exportDashboard(themeId) {
const a = document.createElement('a');
a.href = `/api/dashboards/${themeId}/export`;
a.download = `${themeId}.tsd`;
a.click();
Toast.show('正在下载 .tsd 文件...', 'info');
},
_themeCardHtml(theme) { _themeCardHtml(theme) {
const icon = theme.config?.icon || '📊'; const icon = theme.icon || '📊';
const aspect = theme.config?.aspect_ratio || 'auto'; const aspect = theme.aspect_ratio || 'auto';
return ` return `
<div class="glass-card theme-card" data-theme-id="${theme.id}"> <div class="glass-card theme-card" data-theme-id="${theme.id}">
<div class="theme-card-preview">${icon}</div> <div class="theme-card-preview">${icon}</div>
@@ -121,6 +155,9 @@ const PageDashboard = {
<div class="dashboard-layout"> <div class="dashboard-layout">
<div class="dashboard-sub-sidebar"> <div class="dashboard-sub-sidebar">
<div id="dash-category-list"></div> <div id="dash-category-list"></div>
<div style="padding:8px 12px;margin-top:auto;">
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">导入 .tsd</button>
</div>
</div> </div>
<div class="dashboard-main"> <div class="dashboard-main">
<div id="dash-theme-grid" class="theme-grid"></div> <div id="dash-theme-grid" class="theme-grid"></div>
+36 -3
View File
@@ -48,6 +48,24 @@ const PageScene = {
_bindEvents() { _bindEvents() {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor()); 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'); const grid = document.getElementById('scene-grid');
if (grid) { if (grid) {
@@ -65,6 +83,9 @@ const PageScene = {
} else if (action === 'delete') { } else if (action === 'delete') {
e.stopPropagation(); e.stopPropagation();
this._deleteScene(sceneId); this._deleteScene(sceneId);
} else if (action === 'export') {
e.stopPropagation();
this._exportScene(sceneId);
} }
}); });
} }
@@ -122,6 +143,14 @@ const PageScene = {
this._loadScenes(); 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 `
@@ -138,6 +167,7 @@ const PageScene = {
<div style="margin-top:12px;display:flex;gap:6px;"> <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-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>`;
@@ -176,9 +206,12 @@ const PageScene = {
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'} ${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p> </p>
</div> </div>
<button id="btn-new-scene" class="btn btn-primary" ${!this._currentGameId ? 'disabled' : ''}> <div style="display:flex;gap:8px;">
+ 新建场景 <button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button>
</button> <button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>
+ 新建场景
</button>
</div>
</div> </div>
<div id="scene-grid" class="scene-grid animated"></div>`; <div id="scene-grid" class="scene-grid animated"></div>`;
} }
+64 -64
View File
@@ -18,17 +18,31 @@ const PageSettings = {
Toast.show('遥测端口已更新,重启监听后生效', 'info'); Toast.show('遥测端口已更新,重启监听后生效', 'info');
}); });
document.getElementById('settings-server-port')?.addEventListener('change', async (e) => {
Toast.show('服务器端口修改后需要重启程序', 'warning');
});
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => { document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
await API.stopTelemetry(); await API.stopTelemetry();
await API.startTelemetry(); await API.startTelemetry();
Toast.show('遥测监听已重启', 'success'); Toast.show('遥测监听已重启', 'success');
}); });
document.getElementById('btn-import-plugin')?.addEventListener('click', () => this._importPlugin()); document.getElementById('btn-import-plugin')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.tsp';
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/games/install', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) { Toast.show('游戏插件安装成功', 'success'); this.render(); }
else Toast.show('安装失败', 'error');
} catch (err) { Toast.show('安装失败', 'error'); }
};
input.click();
});
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => { document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
await API.reloadGamePlugins(); await API.reloadGamePlugins();
Toast.show('插件已重新加载', 'success'); Toast.show('插件已重新加载', 'success');
@@ -40,11 +54,11 @@ const PageSettings = {
const removeBtn = e.target.closest('.btn-remove-plugin'); const removeBtn = e.target.closest('.btn-remove-plugin');
if (exportBtn) { if (exportBtn) {
const pluginId = exportBtn.dataset.pluginId; const pluginId = exportBtn.dataset.pluginId;
const data = await API.exportGamePlugin(pluginId); const a = document.createElement('a');
if (data) { a.href = `/api/games/${pluginId}/export`;
this._downloadJson(`plugin_${pluginId}.json`, data); a.download = `${pluginId}.tsp`;
Toast.show('插件已导出', 'success'); a.click();
} Toast.show('正在下载 .tsp 文件...', 'info');
} }
if (removeBtn) { if (removeBtn) {
const pluginId = removeBtn.dataset.pluginId; const pluginId = removeBtn.dataset.pluginId;
@@ -55,40 +69,25 @@ const PageSettings = {
} }
} }
}); });
},
async _importPlugin() { document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.tsd';
input.onchange = async (e) => { input.onchange = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
try { const formData = new FormData();
const text = await file.text(); formData.append('file', file);
const data = JSON.parse(text); try {
if (data.type === 'game_plugin') { const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
await API.installGamePlugin(data); const data = await res.json();
Toast.show('插件安装成功', 'success'); if (data.ok) Toast.show('仪表盘导入成功', 'success');
this.render(); else Toast.show('导入失败', 'error');
} else { } catch (err) { Toast.show('导入失败', 'error'); }
Toast.show('无效的插件文件格式', 'error'); };
} input.click();
} catch (err) { });
Toast.show('文件解析失败: ' + err.message, 'error');
}
};
input.click();
},
_downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}, },
_gamePluginListHtml(games) { _gamePluginListHtml(games) {
@@ -107,7 +106,7 @@ const PageSettings = {
</div> </div>
</div> </div>
<div style="display:flex;gap:6px;"> <div style="display:flex;gap:6px;">
<button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="${g.id}">导出</button> <button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="${g.id}">导出 .tsp</button>
${!g.is_builtin ? `<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="${g.id}">移除</button>` : ''} ${!g.is_builtin ? `<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="${g.id}">移除</button>` : ''}
</div> </div>
</div> </div>
@@ -124,25 +123,16 @@ const PageSettings = {
<div class="settings-row"> <div class="settings-row">
<div> <div>
<div class="settings-label">遥测监听端口</div> <div class="settings-label">遥测监听端口</div>
<div class="settings-desc">游戏内设置的数据输出端口</div> <div class="settings-desc">游戏内设置的数据输出端口 (默认 20777)</div>
</div> </div>
<div class="settings-control"> <div class="settings-control">
<input type="number" id="settings-telemetry-port" value="${cfg.telemetry_port || 20777}" min="1024" max="65535"> <input type="number" id="settings-telemetry-port" value="${cfg.telemetry_port || 20777}" min="1024" max="65535">
</div> </div>
</div> </div>
<div class="settings-row">
<div>
<div class="settings-label">Web 服务器端口</div>
<div class="settings-desc">Web UI 服务的端口号</div>
</div>
<div class="settings-control">
<input type="number" id="settings-server-port" value="${cfg.server_port || 9527}" min="80" max="65535" disabled>
</div>
</div>
<div class="settings-row"> <div class="settings-row">
<div> <div>
<div class="settings-label">重启遥测监听</div> <div class="settings-label">重启遥测监听</div>
<div class="settings-desc">修改端口或切换游戏后需要重启监听</div> <div class="settings-desc">修改端口或切换游戏后需要重启</div>
</div> </div>
<div class="settings-control"> <div class="settings-control">
<button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button> <button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button>
@@ -150,22 +140,32 @@ const PageSettings = {
</div> </div>
</div> </div>
<div class="settings-section">
<h3>仪表盘管理</h3>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<button id="btn-import-dashboard" class="btn btn-secondary btn-sm">导入 .tsd</button>
<span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center;">
右键点击仪表盘卡片即可导出
</span>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>游戏插件管理</h3> <h3>游戏插件管理</h3>
<div style="display:flex;gap:8px;margin-bottom:16px;"> <div style="display:flex;gap:8px;margin-bottom:16px;">
<button id="btn-import-plugin" class="btn btn-secondary btn-sm">📥 导入插件</button> <button id="btn-import-plugin" class="btn btn-secondary btn-sm">导入 .tsp</button>
<button id="btn-reload-plugins" class="btn btn-secondary btn-sm">🔄 重新加载</button> <button id="btn-reload-plugins" class="btn btn-secondary btn-sm">重新加载</button>
</div> </div>
<div id="settings-game-list"> <div id="settings-game-list">
${this._gamePluginListHtml(games)} ${this._gamePluginListHtml(games)}
</div> </div>
<div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6;"> <div class="glass-card" style="padding:16px;margin-top:12px;font-size:12px;color:var(--text-tertiary);line-height:1.6;">
<strong style="color:var(--text-secondary);">社区开发指南:</strong><br> <strong style="color:var(--text-secondary);">社区插件开发指南:</strong><br>
1. 创建一个包含 <code>manifest.json</code> <code>parser.py</code> 的文件夹<br> 1. 创建包含 <code>manifest.json</code> + <code>parser.py</code> 的文件夹<br>
2. <code>manifest.json</code> 定义游戏元信息,<code>parser.py</code> 实现 <code>get_parser()</code> 函数<br> 2. <code>manifest.json</code> 定义元信息,<code>parser.py</code> 需有 <code>get_parser()</code> 函数<br>
3. <code>get_parser()</code> 返回对象实现 <code>game_id()</code> 和 <code>parse(data, addr)</code> 方法<br> 3. 返回对象实现 <code>game_id()</code> 和 <code>parse(data, addr)</code> 方法<br>
4. 通过"导入插件"或放入 <code>games/user/</code> 目录安装<br> 4. 打包时只需要 zip 这两个文件,重命名后缀为 <code>.tsp</code> 即可导入<br>
5. 导出你的插件分享给社区! 5. 完整的 <code>TelemetryData</code> 字段参考见 <code>server/telemetry/data.py</code>
</div> </div>
</div> </div>
@@ -174,7 +174,7 @@ const PageSettings = {
<div class="settings-row"> <div class="settings-row">
<div> <div>
<div class="settings-label">TurboSu</div> <div class="settings-label">TurboSu</div>
<div class="settings-desc">赛车遥测仪表盘 v1.0.0</div> <div class="settings-desc">赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)</div>
</div> </div>
</div> </div>
</div> </div>