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)
async def dashboard_render(request: Request, theme_id: str):
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)
template_html = dashboard_manager.get_template(theme_id)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"theme": theme,
"template_html": template_html,
})
if not theme:
raise HTTPException(status_code=404, detail="Dashboard not found")
return HTMLResponse(content=f"""
<!DOCTYPE html><html><body style="background:#000;color:#888;display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;">
仪表盘 "{theme['name']}" 缺少 index.html
</body></html>
""")
@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
import io
import json
import os
import shutil
import uuid
import zipfile
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
@@ -14,87 +15,142 @@ from utils.logger import get_logger
logger = get_logger(__name__)
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"
@dataclass
class DashboardTheme:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
id: str = ""
name: str = ""
category: str = "basic"
description: str = ""
author: str = ""
version: str = "1.0.0"
preview: str = ""
config: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
icon: str = "📊"
aspect_ratio: str = "auto"
render_mode: str = "contain"
created_at: str = ""
updated_at: str = ""
is_builtin: bool = False
dir_path: str = ""
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
def from_dict(cls, d: dict[str, Any]) -> DashboardTheme:
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
def from_manifest(cls, manifest_path: Path, is_builtin: bool = False) -> DashboardTheme | None:
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:
def __init__(self):
DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True)
self._cache: dict[str, DashboardTheme] = {}
self._load_builtins()
USER_DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True)
def _load_builtins(self):
if not BUILTIN_DASHBOARDS_DIR.exists():
return
for item in BUILTIN_DASHBOARDS_DIR.iterdir():
if item.is_dir():
cfg_file = item / "config.json"
if cfg_file.exists():
try:
with open(cfg_file, "r", encoding="utf-8") as f:
data = json.load(f)
theme = DashboardTheme.from_dict(data)
theme.is_builtin = True
self._cache[theme.id] = theme
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 _scan_dirs(self, base_dir: Path, is_builtin: bool) -> list[DashboardTheme]:
themes = []
if not base_dir.exists():
return themes
for item in sorted(base_dir.iterdir()):
if not item.is_dir():
continue
manifest = item / "manifest.json"
if manifest.exists():
theme = DashboardTheme.from_manifest(manifest, is_builtin)
if theme:
themes.append(theme)
return themes
def list_all(self, category: str | None = None) -> list[dict[str, Any]]:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
result = [t.to_dict() for t in self._cache.values()]
themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True)
themes += self._scan_dirs(USER_DASHBOARDS_DIR, False)
result = [t.to_dict() for t in themes]
if category and category != "all":
result = [r for r in result if r.get("category") == category]
return result
def get(self, theme_id: str) -> dict[str, Any] | None:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
theme = self._cache.get(theme_id)
return theme.to_dict() if theme else None
for base, is_builtin in [(BUILTIN_DASHBOARDS_DIR, True), (USER_DASHBOARDS_DIR, False)]:
theme_dir = base / theme_id
manifest = theme_dir / "manifest.json"
if manifest.exists():
theme = DashboardTheme.from_manifest(manifest, is_builtin)
if theme:
return theme.to_dict()
return None
def save(self, theme: DashboardTheme) -> bool:
theme.updated_at = datetime.now().isoformat()
theme.is_builtin = False
filepath = DASHBOARDS_DIR / f"{theme.id}.json"
def get_dir(self, theme_id: str) -> Path | None:
for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]:
theme_dir = base / theme_id
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:
with open(filepath, "w", encoding="utf-8") as f:
json.dump(theme.to_dict(), f, indent=2, ensure_ascii=False)
with open(theme_dir / "manifest.json", "w", encoding="utf-8") as f:
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)
return True
except Exception as e:
@@ -102,69 +158,79 @@ class DashboardManager:
return False
def delete(self, theme_id: str) -> bool:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
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()
theme_dir = USER_DASHBOARDS_DIR / theme_id
if theme_dir.exists():
shutil.rmtree(theme_dir)
logger.info("Dashboard deleted: %s", theme_id)
return True
return True
return False
def export_theme(self, theme_id: str) -> dict[str, Any] | None:
theme = self.get(theme_id)
if not theme:
def export_theme_zip(self, theme_id: str) -> bytes | None:
theme_dir = self.get_dir(theme_id)
if not theme_dir:
return None
result = {
"type": "dashboard_theme",
"version": "1.0",
"data": theme,
"html": self._read_template_file(theme["id"]),
}
return result
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in sorted(theme_dir.rglob('*')):
if f.is_file():
arcname = str(f.relative_to(theme_dir))
zf.write(f, arcname)
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:
if data.get("type") != "dashboard_theme":
def import_theme_zip(self, zip_data: bytes) -> bool:
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
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]:
self._cache.clear()
self._load_builtins()
self._load_user_dashboards()
cats = set()
for t in self._cache.values():
if t.category:
cats.add(t.category)
return sorted(cats)
themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True)
themes += self._scan_dirs(USER_DASHBOARDS_DIR, False)
cats = sorted(set(t.category for t in themes if t.category))
return cats
dashboard_manager = DashboardManager()
+22 -15
View File
@@ -1,7 +1,10 @@
from __future__ import annotations
import io
import json
import shutil
import uuid
import zipfile
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
@@ -124,24 +127,28 @@ class SceneManager:
return True
return False
def export_scene(self, scene_id: str) -> dict[str, Any] | None:
scene = self.get(scene_id)
if not scene:
def export_scene_zip(self, scene_id: str) -> bytes | None:
filepath = SCENES_DIR / f"{scene_id}.json"
if not filepath.exists():
return None
return {
"type": "scene",
"version": "1.0",
"data": scene,
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(filepath, "scene.json")
logger.info("Scene exported as zip: %s", scene_id)
return buf.getvalue()
def import_scene(self, data: dict[str, Any]) -> bool:
if data.get("type") != "scene":
def import_scene_zip(self, zip_data: bytes) -> bool:
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
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()
+61 -28
View File
@@ -103,22 +103,23 @@ async def api_get_dashboard(theme_id: str):
@router.get("/dashboards/{theme_id}/template")
async def api_get_dashboard_template(theme_id: str):
tmpl = dashboard_manager.get_template(theme_id)
return {"html": tmpl}
tmpl = dashboard_manager.get_index_html(theme_id)
return {"html": tmpl or ""}
@router.post("/dashboards")
async def api_create_dashboard(data: dict[str, Any]):
cfg = data.get("config", {})
theme = DashboardTheme(
name=data.get("name", "Untitled"),
category=data.get("category", "basic"),
description=data.get("description", ""),
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)
if data.get("template_html"):
dashboard_manager._save_template_file(theme.id, data["template_html"])
dashboard_manager.save(theme, data.get("index_html", ""))
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)
if not existing:
raise HTTPException(404, "Dashboard not found")
theme = DashboardTheme.from_dict(existing)
for k in ["name", "category", "description", "author", "config"]:
if k in data:
setattr(theme, k, data[k])
dashboard_manager.save(theme)
if "template_html" in data:
dashboard_manager._save_template_file(theme.id, data["template_html"])
theme = DashboardTheme(
id=theme_id,
name=data.get("name", existing.get("name", "")),
category=data.get("category", existing.get("category", "basic")),
description=data.get("description", existing.get("description", "")),
author=data.get("author", existing.get("author", "")),
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()
@@ -145,15 +150,25 @@ async def api_delete_dashboard(theme_id: str):
@router.get("/dashboards/{theme_id}/export")
async def api_export_dashboard(theme_id: str):
result = dashboard_manager.export_theme(theme_id)
if not result:
from fastapi.responses import Response
zip_bytes = dashboard_manager.export_theme_zip(theme_id)
if not zip_bytes:
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")
async def api_import_dashboard(data: dict[str, Any]):
ok = dashboard_manager.import_theme(data)
async def api_import_dashboard(file: UploadFile = File(...)):
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}
@@ -224,15 +239,25 @@ async def api_delete_scene(scene_id: str):
@router.get("/scenes/{scene_id}/export")
async def api_export_scene(scene_id: str):
result = scene_manager.export_scene(scene_id)
if not result:
from fastapi.responses import Response
zip_bytes = scene_manager.export_scene_zip(scene_id)
if not zip_bytes:
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")
async def api_import_scene(data: dict[str, Any]):
ok = scene_manager.import_scene(data)
async def api_import_scene(file: UploadFile = File(...)):
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}
@@ -254,8 +279,11 @@ async def api_get_game(plugin_id: str):
@router.post("/games/install")
async def api_install_game_plugin(data: dict[str, Any]):
ok = game_plugin_manager.install_plugin(data)
async def api_install_game_plugin(file: UploadFile = File(...)):
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}
@@ -267,10 +295,15 @@ async def api_remove_game_plugin(plugin_id: str):
@router.get("/games/{plugin_id}/export")
async def api_export_game_plugin(plugin_id: str):
result = game_plugin_manager.export_plugin(plugin_id)
if not result:
from fastapi.responses import Response
zip_bytes = game_plugin_manager.export_plugin_zip(plugin_id)
if not zip_bytes:
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")
+52 -53
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import io
import json
import shutil
import zipfile
import importlib.util
import sys
from dataclasses import dataclass, field, asdict
@@ -127,65 +130,61 @@ class GamePluginManager:
self._discover()
self._parser_cache.clear()
def install_plugin(self, data: dict[str, Any]) -> bool:
plugin_id = data.get("id", "")
if not plugin_id:
def install_plugin_zip(self, zip_data: bytes) -> bool:
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 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
dest_dir = USER_DIR / plugin_id
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:
def export_plugin_zip(self, plugin_id: str) -> bytes | None:
gp = self._plugins.get(plugin_id)
if not gp:
return None
parser_dir = Path(gp.manifest_path)
manifest_file = parser_dir / "manifest.json"
parser_file = parser_dir / "parser.py"
result: dict[str, Any] = {
"type": "game_plugin",
"version": "1.0",
"manifest": {},
"parser_code": "",
}
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
if not parser_dir.exists():
return None
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in sorted(parser_dir.rglob('*')):
if f.is_file():
zf.write(f, f.relative_to(parser_dir))
logger.info("Plugin exported as zip: %s", plugin_id)
return buf.getvalue()
game_plugin_manager = GamePluginManager()
-3
View File
@@ -43,7 +43,6 @@ const API = {
async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); },
async deleteDashboard(id) { return this.del(`/dashboards/${id}`); },
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 getScene(id) { return this.get(`/scenes/${id}`); },
@@ -51,13 +50,11 @@ const API = {
async updateScene(id, data) { return this.put(`/scenes/${id}`, data); },
async deleteScene(id) { return this.del(`/scenes/${id}`); },
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 stopTelemetry() { return this.post('/telemetry/stop'); },
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 exportGamePlugin(id) { return this.get(`/games/${id}/export`); },
async reloadGamePlugins() { return this.post('/games/reload'); },
+39 -2
View File
@@ -83,7 +83,33 @@ const PageDashboard = {
const themeId = card.dataset.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) {
@@ -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) {
const icon = theme.config?.icon || '📊';
const aspect = theme.config?.aspect_ratio || 'auto';
const icon = theme.icon || '📊';
const aspect = theme.aspect_ratio || 'auto';
return `
<div class="glass-card theme-card" data-theme-id="${theme.id}">
<div class="theme-card-preview">${icon}</div>
@@ -121,6 +155,9 @@ const PageDashboard = {
<div class="dashboard-layout">
<div class="dashboard-sub-sidebar">
<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 class="dashboard-main">
<div id="dash-theme-grid" class="theme-grid"></div>
+36 -3
View File
@@ -48,6 +48,24 @@ const PageScene = {
_bindEvents() {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor());
document.getElementById('btn-import-scene')?.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.tss';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/scenes/import', { method: 'POST', body: formData });
const data = await res.json();
if (data.ok) { Toast.show('场景导入成功', 'success'); this._loadScenes(); }
else Toast.show('导入失败', 'error');
} catch (err) { Toast.show('导入失败', 'error'); }
};
input.click();
});
const grid = document.getElementById('scene-grid');
if (grid) {
@@ -65,6 +83,9 @@ const PageScene = {
} else if (action === 'delete') {
e.stopPropagation();
this._deleteScene(sceneId);
} else if (action === 'export') {
e.stopPropagation();
this._exportScene(sceneId);
}
});
}
@@ -122,6 +143,14 @@ const PageScene = {
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) {
const canvasCount = (scene.canvases || []).length;
return `
@@ -138,6 +167,7 @@ const PageScene = {
<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-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>
</div>
</div>`;
@@ -176,9 +206,12 @@ const PageScene = {
${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}
</p>
</div>
<button id="btn-new-scene" class="btn btn-primary" ${!this._currentGameId ? 'disabled' : ''}>
+ 新建场景
</button>
<div style="display:flex;gap:8px;">
<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>
</div>
</div>
<div id="scene-grid" class="scene-grid animated"></div>`;
}
+64 -64
View File
@@ -18,17 +18,31 @@ const PageSettings = {
Toast.show('遥测端口已更新,重启监听后生效', 'info');
});
document.getElementById('settings-server-port')?.addEventListener('change', async (e) => {
Toast.show('服务器端口修改后需要重启程序', 'warning');
});
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
await API.stopTelemetry();
await API.startTelemetry();
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 () => {
await API.reloadGamePlugins();
Toast.show('插件已重新加载', 'success');
@@ -40,11 +54,11 @@ const PageSettings = {
const removeBtn = e.target.closest('.btn-remove-plugin');
if (exportBtn) {
const pluginId = exportBtn.dataset.pluginId;
const data = await API.exportGamePlugin(pluginId);
if (data) {
this._downloadJson(`plugin_${pluginId}.json`, data);
Toast.show('插件已导出', 'success');
}
const a = document.createElement('a');
a.href = `/api/games/${pluginId}/export`;
a.download = `${pluginId}.tsp`;
a.click();
Toast.show('正在下载 .tsp 文件...', 'info');
}
if (removeBtn) {
const pluginId = removeBtn.dataset.pluginId;
@@ -55,40 +69,25 @@ const PageSettings = {
}
}
});
},
async _importPlugin() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (data.type === 'game_plugin') {
await API.installGamePlugin(data);
Toast.show('插件安装成功', 'success');
this.render();
} else {
Toast.show('无效的插件文件格式', 'error');
}
} 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);
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');
else Toast.show('导入失败', 'error');
} catch (err) { Toast.show('导入失败', 'error'); }
};
input.click();
});
},
_gamePluginListHtml(games) {
@@ -107,7 +106,7 @@ const PageSettings = {
</div>
</div>
<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>` : ''}
</div>
</div>
@@ -124,25 +123,16 @@ const PageSettings = {
<div class="settings-row">
<div>
<div class="settings-label">遥测监听端口</div>
<div class="settings-desc">游戏内设置的数据输出端口</div>
<div class="settings-desc">游戏内设置的数据输出端口 (默认 20777)</div>
</div>
<div class="settings-control">
<input type="number" id="settings-telemetry-port" value="${cfg.telemetry_port || 20777}" min="1024" max="65535">
</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>
<div class="settings-label">重启遥测监听</div>
<div class="settings-desc">修改端口或切换游戏后需要重启监听</div>
<div class="settings-desc">修改端口或切换游戏后需要重启</div>
</div>
<div class="settings-control">
<button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button>
@@ -150,22 +140,32 @@ const PageSettings = {
</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">
<h3>游戏插件管理</h3>
<div style="display:flex;gap:8px;margin-bottom:16px;">
<button id="btn-import-plugin" class="btn btn-secondary btn-sm">📥 导入插件</button>
<button id="btn-reload-plugins" 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>
</div>
<div id="settings-game-list">
${this._gamePluginListHtml(games)}
</div>
<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>
1. 创建一个包含 <code>manifest.json</code> <code>parser.py</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>
4. 通过"导入插件"或放入 <code>games/user/</code> <br>
5. 导出你的插件分享给社区
<strong style="color:var(--text-secondary);">社区插件开发指南</strong><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>
3. 返回对象实现 <code>game_id()</code> <code>parse(data, addr)</code> <br>
4. 打包时只需要 zip 这两个文件重命名后缀为 <code>.tsp</code> <br>
5. 完整的 <code>TelemetryData</code> <code>server/telemetry/data.py</code>
</div>
</div>
@@ -174,7 +174,7 @@ const PageSettings = {
<div class="settings-row">
<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>