feat: TurboSu initial release - racing telemetry dashboard

This commit is contained in:
Yei.J. (AskaEth)
2026-07-25 15:23:40 +08:00
commit d61bb61a83
61 changed files with 5270 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>TurboSu - {{ theme.name if theme else 'Dashboard' }}</title>
<style>
:root {
--aspect-ratio: {{ theme.config.aspect_ratio | default('auto') if theme else 'auto' }};
--render-mode: {{ theme.config.render_mode | default('contain') if theme else 'contain' }};
}
* { 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', 'MiSans', system-ui, sans-serif;
-webkit-tap-highlight-color: transparent;
}
#dashboard-root {
width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
#dashboard-letterbox {
position: relative;
overflow: hidden;
}
#dashboard-content {
width: 100%; height: 100%;
}
.loading {
display: flex; align-items: center; justify-content: center;
height: 100%; font-size: 24px; color: #888;
}
/* Aspect ratio constraint: "contain" mode */
#dashboard-letterbox.contain {
max-width: 100vw;
max-height: 100vh;
}
/* Aspect ratio constraint: "cover" mode */
#dashboard-letterbox.cover {
min-width: 100vw;
min-height: 100vh;
}
/* Aspect ratio constraint: "fill" mode */
#dashboard-letterbox.fill {
width: 100vw;
height: 100vh;
}
/* Aspect ratio constraint: "center" mode */
#dashboard-letterbox.center {
width: auto;
height: auto;
}
</style>
</head>
<body>
<div id="dashboard-root">
<div id="dashboard-letterbox" class="{{ theme.config.render_mode | default('contain') if theme else 'contain' }}">
<div id="dashboard-content">
{{ template_html | safe if template_html else '<div class="loading">加载仪表盘中...</div>' }}
</div>
</div>
</div>
<script>
const THEME_ID = "{{ theme.id if theme else '' }}";
const THEME_CONFIG = {{ theme.config | tojson if theme and theme.config else '{}' }};
</script>
<script>
(function() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
const letterbox = document.getElementById('dashboard-letterbox');
const root = document.getElementById('dashboard-root');
let ws;
let reconnectTimer;
function parseAspectRatio(ratio) {
if (!ratio || ratio === 'auto') return null;
const parts = ratio.split(':');
if (parts.length === 2) {
const w = parseFloat(parts[0]);
const h = parseFloat(parts[1]);
if (w > 0 && h > 0) return w / h;
}
return null;
}
function applyAspectRatio() {
const ratio = parseAspectRatio(THEME_CONFIG.aspect_ratio || 'auto');
const mode = THEME_CONFIG.render_mode || 'contain';
if (!ratio) {
letterbox.style.width = '100vw';
letterbox.style.height = '100vh';
letterbox.classList.add('fill');
return;
}
const vw = window.innerWidth;
const vh = window.innerHeight;
const vr = vw / vh;
let cw, ch;
switch (mode) {
case 'contain':
if (vr > ratio) {
ch = vh;
cw = vh * ratio;
} else {
cw = vw;
ch = vw / ratio;
}
letterbox.classList.add('contain');
break;
case 'cover':
if (vr > ratio) {
cw = vw;
ch = vw / ratio;
} else {
ch = vh;
cw = vh * ratio;
}
letterbox.classList.add('cover');
break;
case 'fill':
letterbox.style.width = '100vw';
letterbox.style.height = '100vh';
letterbox.classList.add('fill');
return;
case 'center':
const maxW = THEME_CONFIG.max_width || 1920;
const maxH = THEME_CONFIG.max_height || 1080;
cw = Math.min(vw, maxW);
ch = cw / ratio;
if (ch > vh) {
ch = Math.min(vh, maxH);
cw = ch * ratio;
}
letterbox.classList.add('center');
break;
default:
if (vr > ratio) {
ch = vh;
cw = vh * ratio;
} else {
cw = vw;
ch = vw / ratio;
}
letterbox.classList.add('contain');
}
if (mode !== 'fill') {
letterbox.style.width = cw + 'px';
letterbox.style.height = ch + 'px';
}
}
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[Dashboard] WS connected');
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'telemetry') updateDashboard(msg.data);
} catch(e) {}
};
ws.onclose = () => {
console.log('[Dashboard] WS closed, reconnecting...');
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = (e) => console.error('[Dashboard] WS error', e);
}
function updateDashboard(data) {
document.querySelectorAll('[data-bind]').forEach(el => {
const bind = el.getAttribute('data-bind');
const value = bind.split('.').reduce((o, k) => o?.[k], data);
if (value !== undefined && value !== null) {
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.value = typeof value === 'number' ? value.toFixed(1) : value;
} else {
el.textContent = typeof value === 'number' ? value.toFixed(1) : value;
}
}
});
document.querySelectorAll('[data-bind-rpm]').forEach(el => {
const value = data.rpm || 0;
const max = data.max_rpm || 8000;
const pct = Math.min(value / max, 1);
el.style.setProperty('--rpm-pct', pct);
el.style.setProperty('--rpm', value);
el.style.setProperty('--rpm-max', max);
});
document.querySelectorAll('[data-bind-speed]').forEach(el => {
el.style.setProperty('--speed', data.speed_kmh || 0);
});
document.querySelectorAll('[data-bind-gear]').forEach(el => {
const g = data.gear || 0;
el.style.setProperty('--gear', g);
});
}
applyAspectRatio();
window.addEventListener('resize', applyAspectRatio);
connect();
})();
</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TurboSu - 赛车遥测仪表盘</title>
<link rel="stylesheet" href="/static/css/miuix.css">
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body class="theme-dark">
<div id="app" class="app-layout">
<aside id="sidebar" class="sidebar expanded">
<div class="sidebar-header">
<div class="sidebar-logo">
<svg viewBox="0 0 48 48" width="40" height="40">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="16" font-weight="bold">S</text>
</svg>
</div>
<div class="sidebar-title">TurboSu</div>
<button id="sidebar-toggle" class="sidebar-toggle-btn" title="折叠侧边栏">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M15.41 16.59L10.83 12l4.58-4.59L14 6l-6 6 6 6z" fill="currentColor"/></svg>
</button>
</div>
<nav class="sidebar-nav">
<a href="#/home" class="nav-item" data-route="home">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z" fill="currentColor"/></svg>
<span class="nav-label">首页</span>
</a>
<a href="#/dashboard" class="nav-item" data-route="dashboard">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-12c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z" fill="currentColor"/></svg>
<span class="nav-label">仪表盘</span>
</a>
<div id="game-selector-container" class="nav-section">
<button id="game-selector-toggle" class="nav-item collapsible-toggle collapsed">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 10H3V8h18v8zM6 15h2v-2h2v-2H8V9H6v2H4v2h2z" fill="currentColor"/></svg>
<span class="nav-label" id="game-selector-label">选择游戏</span>
<svg class="collapse-arrow" viewBox="0 0 24 24" width="16" height="16"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6z" fill="currentColor"/></svg>
</button>
<div id="game-list" class="nav-submenu collapsed">
</div>
</div>
<div id="scene-nav-container" class="nav-section">
<a href="#/scene" class="nav-item" data-route="scene">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z" fill="currentColor"/></svg>
<span class="nav-label">场景</span>
</a>
</div>
</nav>
<div class="sidebar-bottom">
<a href="#/debug" class="nav-item" data-route="debug">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z" fill="currentColor"/></svg>
<span class="nav-label">数据测试</span>
</a>
<a href="#/settings" class="nav-item" data-route="settings">
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" fill="currentColor"/></svg>
<span class="nav-label">设置</span>
</a>
</div>
</aside>
<main class="main-content">
<header id="topbar" class="topbar">
<div class="topbar-left">
<div id="topbar-logo-title" class="topbar-logo-title hidden">
<svg viewBox="0 0 48 48" width="28" height="28">
<circle cx="24" cy="24" r="22" fill="none" stroke="var(--accent)" stroke-width="2.5"/>
<path d="M24 6 L30 20 L44 24 L30 28 L24 42 L18 28 L4 24 L18 20 Z" fill="var(--accent)"/>
</svg>
<span class="topbar-title-text">TurboSu</span>
</div>
</div>
<div class="topbar-right">
<div id="connection-status" class="status-indicator disconnected">
<span class="status-dot"></span>
<span class="status-text">未连接</span>
</div>
<button id="theme-toggle" class="icon-btn" title="切换日夜间模式">
<svg class="icon-sun" viewBox="0 0 24 24" width="20" height="20"><path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM4 10.5H1v2h3zm9-9.95h-2V3.5h2zm7.45 3.91l-1.41-1.41-1.79 1.79 1.41 1.41zm-3.21 13.7l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5v2h3v-2zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm-1 16.95h2V19.5h-2zm-7.45-3.91l1.41 1.41 1.79-1.8-1.41-1.41z" fill="currentColor"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" width="20" height="20"><path d="M9.37 5.51c-.18.64-.27 1.31-.27 1.99 0 4.08 3.32 7.4 7.4 7.4.68 0 1.35-.09 1.99-.27C17.45 17.19 14.93 19 12 19c-3.86 0-7-3.14-7-7 0-2.93 1.81-5.45 4.37-6.49z" fill="currentColor"/></svg>
</button>
</div>
</header>
<div id="page-container" class="page-container">
<div id="page-home" class="page active">
</div>
<div id="page-dashboard" class="page">
</div>
<div id="page-scene" class="page">
</div>
<div id="page-debug" class="page">
</div>
<div id="page-settings" class="page">
</div>
</div>
</main>
</div>
<script src="/static/js/utils/theme.js"></script>
<script src="/static/js/utils/toast.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/ws.js"></script>
<script src="/static/js/components/sidebar.js"></script>
<script src="/static/js/components/topbar.js"></script>
<script src="/static/js/pages/home.js"></script>
<script src="/static/js/pages/dashboard.js"></script>
<script src="/static/js/pages/scene.js"></script>
<script src="/static/js/pages/debug.js"></script>
<script src="/static/js/pages/settings.js"></script>
<script src="/static/js/router.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TurboSu - {{ scene.name if scene else 'Scene' }}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, sans-serif; }
#scene-root {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
#scene-canvas {
position: relative;
overflow: hidden;
}
.scene-dashboard {
position: absolute;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
background: rgba(0,0,0,0.3);
}
.scene-dashboard iframe {
width: 100%; height: 100%; border: none;
}
.loading-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.9); z-index: 999;
flex-direction: column; gap: 16px;
}
.loading-overlay select {
padding: 10px 20px; font-size: 16px; border-radius: 8px;
background: rgba(255,255,255,0.1); color: #fff;
border: 1px solid rgba(255,255,255,0.3); cursor: pointer;
}
.loading-overlay button {
padding: 12px 40px; font-size: 18px; border-radius: 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; border: none; cursor: pointer; font-weight: bold;
}
.loading-overlay button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div id="scene-root">
<div id="loading-overlay" class="loading-overlay">
<h2 style="color:#fff;font-size:24px;margin-bottom:8px;">选择画布比例</h2>
<select id="canvas-select">
</select>
<button id="start-render-btn">开始渲染</button>
</div>
<div id="scene-canvas"></div>
</div>
<script>
const SCENE_DATA = {{ scene | tojson if scene else '{}' }};
</script>
<script>
(function() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}/ws`;
const sceneRoot = document.getElementById('scene-root');
const sceneCanvas = document.getElementById('scene-canvas');
const loadingOverlay = document.getElementById('loading-overlay');
const canvasSelect = document.getElementById('canvas-select');
const startBtn = document.getElementById('start-render-btn');
const canvases = SCENE_DATA.canvases || [];
if (canvases.length === 0) {
loadingOverlay.innerHTML = '<p style="color:#f44;">该场景没有配置画布</p>';
} else if (canvases.length === 1) {
loadingOverlay.style.display = 'none';
setupCanvas(canvases[0]);
}
canvases.forEach((c, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = `${c.label || 'Custom'} (${c.width}x${c.height})`;
canvasSelect.appendChild(opt);
});
startBtn.addEventListener('click', () => {
const idx = parseInt(canvasSelect.value);
if (idx >= 0 && idx < canvases.length) {
loadingOverlay.style.display = 'none';
setupCanvas(canvases[idx]);
}
});
function setupCanvas(canvas) {
const cw = canvas.width;
const ch = canvas.height;
const vw = window.innerWidth;
const vh = window.innerHeight;
const scaleX = vw / cw;
const scaleY = vh / ch;
const scale = Math.min(scaleX, scaleY);
sceneCanvas.style.width = (cw * scale) + 'px';
sceneCanvas.style.height = (ch * scale) + 'px';
const placements = canvas.placements || [];
placements.forEach(p => {
const el = document.createElement('div');
el.className = 'scene-dashboard';
el.style.left = (p.x * scale) + 'px';
el.style.top = (p.y * scale) + 'px';
el.style.width = (p.width * scale) + 'px';
el.style.height = (p.height * scale) + 'px';
el.style.zIndex = p.z_index || 0;
const iframe = document.createElement('iframe');
iframe.src = `/dashboard/${p.dashboard_id}`;
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin');
el.appendChild(iframe);
sceneCanvas.appendChild(el);
});
}
window.addEventListener('resize', () => {
const cw = parseInt(sceneCanvas.style.width) / (parseInt(sceneCanvas.style.width) / 1920) || 1920;
const ch = parseInt(sceneCanvas.style.height) / (parseInt(sceneCanvas.style.height) / 1080) || 1080;
const vw = window.innerWidth;
const vh = window.innerHeight;
const scale = Math.min(vw / cw, vh / ch);
sceneCanvas.style.width = (cw * scale) + 'px';
sceneCanvas.style.height = (ch * scale) + 'px';
});
})();
</script>
</body>
</html>