feat: full i18n support - all pages use Lang.t() for zh/en

This commit is contained in:
2026-07-27 20:31:41 +08:00
parent 860bc015e8
commit de58a1c42f
10 changed files with 578 additions and 282 deletions
+2 -2
View File
@@ -49,7 +49,7 @@ const Sidebar = {
this._gameListEl.innerHTML = ''; this._gameListEl.innerHTML = '';
if (!Array.isArray(games) || games.length === 0) { if (!Array.isArray(games) || games.length === 0) {
this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">暂无可用的游戏插件</div>'; this._gameListEl.innerHTML = '<div style="padding:8px 12px;color:var(--text-tertiary);font-size:12px;">' + Lang.t('noGamePlugins') + '</div>';
return; return;
} }
@@ -77,7 +77,7 @@ const Sidebar = {
return; return;
} }
} }
this._gameLabelEl.textContent = '选择游戏'; this._gameLabelEl.textContent = Lang.t('selectGame');
}, },
async _selectGame(gameId) { async _selectGame(gameId) {
+4 -4
View File
@@ -8,15 +8,15 @@ const Topbar = {
if (!WS.connected) { if (!WS.connected) {
WS._reconnectDelay = 500; WS._reconnectDelay = 500;
WS._connect(); WS._connect();
Toast.show('正在重连...', 'info', 1500); Toast.show(Lang.t('loading'), 'info', 1500);
} }
}); });
WS.on('status', (status) => { WS.on('status', (status) => {
this._statusEl.className = `status-indicator ${status}`; this._statusEl.className = `status-indicator ${status}`;
this._statusEl.querySelector('.status-text').textContent = this._statusEl.querySelector('.status-text').textContent =
status === 'connected' ? '已连接' : status === 'connected' ? Lang.t('connected') :
status === 'error' ? '连接错误' : '未连接'; status === 'error' ? Lang.t('connectionError') : Lang.t('disconnected');
}); });
setInterval(() => this._refreshStatus(), 5000); setInterval(() => this._refreshStatus(), 5000);
@@ -28,7 +28,7 @@ const Topbar = {
const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected'; const wsStatus = status.ws_clients > 0 ? 'connected' : 'disconnected';
this._statusEl.className = `status-indicator ${wsStatus}`; this._statusEl.className = `status-indicator ${wsStatus}`;
this._statusEl.querySelector('.status-text').textContent = this._statusEl.querySelector('.status-text').textContent =
wsStatus === 'connected' ? `已连接 (${status.ws_clients})` : '未连接'; wsStatus === 'connected' ? `${Lang.t('connected')} (${status.ws_clients})` : Lang.t('disconnected');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
}; };
+43 -43
View File
@@ -27,7 +27,7 @@ const PageDashboard = {
const allItem = document.createElement('div'); const allItem = document.createElement('div');
allItem.className = 'category-item active'; allItem.className = 'category-item active';
allItem.textContent = '全部'; allItem.textContent = Lang.t('allCategory');
allItem.dataset.cat = 'all'; allItem.dataset.cat = 'all';
listEl.appendChild(allItem); listEl.appendChild(allItem);
@@ -46,7 +46,7 @@ const PageDashboard = {
async _loadThemes() { async _loadThemes() {
const gridEl = document.getElementById('dash-theme-grid'); const gridEl = document.getElementById('dash-theme-grid');
if (!gridEl) return; if (!gridEl) return;
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>'; gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">' + Lang.t('loading') + '</div>';
if (this._marketMode) { if (this._marketMode) {
await this._loadMarket(gridEl); await this._loadMarket(gridEl);
@@ -61,9 +61,9 @@ const PageDashboard = {
if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); } if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(searchQuery)); }
const sortBy = document.getElementById('dash-sort')?.value || 'name'; const sortBy = document.getElementById('dash-sort')?.value || 'name';
themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||'')); themes.sort((a, b) => (a[sortBy]||'').localeCompare(b[sortBy]||''));
if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml('当前游戏没有兼容的仪表盘'); return; } if (themes.length === 0) { gridEl.innerHTML = this._emptyHtml(Lang.t('noCompatibleDash')); return; }
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join(''); gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; } } catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('loadFailed') + '</div>'; }
}, },
async _loadMarket(gridEl) { async _loadMarket(gridEl) {
@@ -71,7 +71,7 @@ const PageDashboard = {
const res = await fetch('/api/market/dashboards'); const res = await fetch('/api/market/dashboards');
const data = await res.json(); const data = await res.json();
const items = data.items || []; const items = data.items || [];
if (!items.length) { gridEl.innerHTML = this._emptyHtml('市场暂无仪表盘'); return; } if (!items.length) { gridEl.innerHTML = this._emptyHtml(Lang.t('marketEmpty')); return; }
gridEl.innerHTML = items.map(d => { gridEl.innerHTML = items.map(d => {
const icon = d.icon || '📦'; const icon = d.icon || '📦';
return `<div class="glass-card theme-card" data-market-id="${d.id}"> return `<div class="glass-card theme-card" data-market-id="${d.id}">
@@ -82,12 +82,12 @@ const PageDashboard = {
<span>${d.category||''}</span><span>v${d.version||'1.0'}</span> <span>${d.category||''}</span><span>v${d.version||'1.0'}</span>
</div> </div>
<div class="theme-card-meta" style="margin-top:4px"> <div class="theme-card-meta" style="margin-top:4px">
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次下载</span> <span>${d.author||''}</span><span style="color:var(--text-tertiary)">${Lang.t('downloadCountShort').replace('%d', d.downloads||0)}</span>
</div> </div>
<button class="btn btn-sm btn-primary btn-market-install" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">安装</button> <button class="btn btn-sm btn-primary btn-market-install" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">${Lang.t('install')}</button>
</div></div>`; </div></div>`;
}).join(''); }).join('');
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">市场加载失败</div>'; } } catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('marketLoadFailed') + '</div>'; }
}, },
_isGameSupported(theme) { _isGameSupported(theme) {
@@ -101,8 +101,8 @@ const PageDashboard = {
return ` return `
<div class="empty-state" style="grid-column:1/-1;"> <div class="empty-state" style="grid-column:1/-1;">
<svg viewBox="0 0 24 24" width="64" height="64"><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> <svg viewBox="0 0 24 24" width="64" height="64"><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>
<h4>${msg || '暂无仪表盘主题'}</h4> <h4>${msg || Lang.t('noDashboards')}</h4>
<p>创建或导入你的仪表盘主题</p> <p>${Lang.t('createFirstDash')}</p>
</div>`; </div>`;
}, },
@@ -149,8 +149,8 @@ const PageDashboard = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd }); const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); } if (data.ok) { Toast.show(Lang.t('importSuccess'), 'success'); this._loadThemes(); }
else Toast.show('导入失败', 'error'); else Toast.show(Lang.t('importFailed'), 'error');
}; input.click(); }; input.click();
}); });
@@ -171,14 +171,14 @@ const PageDashboard = {
try { try {
const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`); const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`);
const data = await res.json(); const data = await res.json();
if (data.ok) { Toast.show('安装成功', 'success'); this._loadThemes(); } if (data.ok) { Toast.show(Lang.t('installSuccess'), 'success'); this._loadThemes(); }
else Toast.show('安装失败', 'error'); else Toast.show(Lang.t('installFailed'), 'error');
} catch (e) { Toast.show('安装失败', 'error'); } } catch (e) { Toast.show(Lang.t('installFailed'), 'error'); }
}, },
_showMarketUpload() { _showMarketUpload() {
if (!this._marketToken) { if (!this._marketToken) {
Toast.show('请先登录市场账户', 'info'); Toast.show(Lang.t('marketLoginPrompt'), 'info');
this._showMarketLogin(); this._showMarketLogin();
return; return;
} }
@@ -193,8 +193,8 @@ const PageDashboard = {
try { try {
const res = await fetch('/api/market/upload', { method: 'POST', body: fd }); const res = await fetch('/api/market/upload', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
Toast.show(data.error ? '上传失败: ' + data.error : '上传成功', data.error ? 'error' : 'success'); Toast.show(data.error ? Lang.t('uploadFailedMsg') + data.error : Lang.t('uploadSuccess'), data.error ? 'error' : 'success');
} catch (e) { Toast.show('上传失败', 'error'); } } catch (e) { Toast.show(Lang.t('uploadFailed'), 'error'); }
}; input.click(); }; input.click();
}, },
@@ -202,13 +202,13 @@ const PageDashboard = {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay'; overlay.className = 'modal-overlay';
overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px"> overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px">
<h3>市场</h3> <h3>${Lang.t('marketLogin')}</h3>
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email"></div> <div class="form-group"><label>${Lang.t('email')}</label><input id="ml-email" type="email"></div>
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div> <div class="form-group"><label>${Lang.t('password')}</label><input id="ml-pass" type="password"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary btn-sm" id="ml-logout" style="display:none;margin-right:auto">退出</button> <button class="btn btn-secondary btn-sm" id="ml-logout" style="display:none;margin-right:auto">${Lang.t('logout')}</button>
<button class="btn btn-secondary btn-sm" id="ml-register">注册</button> <button class="btn btn-secondary btn-sm" id="ml-register">${Lang.t('register')}</button>
<button class="btn btn-primary btn-sm" id="ml-login">登录</button> <button class="btn btn-primary btn-sm" id="ml-login">${Lang.t('login')}</button>
</div></div>`; </div></div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -221,7 +221,7 @@ const PageDashboard = {
localStorage.removeItem('turbosu-market-token'); localStorage.removeItem('turbosu-market-token');
overlay.remove(); overlay.remove();
this._updateMarketUI(); this._updateMarketUI();
Toast.show('已退出', 'info'); Toast.show(Lang.t('loggedOut'), 'info');
}); });
} }
@@ -234,17 +234,17 @@ const PageDashboard = {
if (data.token) { if (data.token) {
this._marketToken = data.token; this._marketToken = data.token;
localStorage.setItem('turbosu-market-token', data.token); localStorage.setItem('turbosu-market-token', data.token);
Toast.show('登录成功', 'success'); Toast.show(Lang.t('loginSuccess'), 'success');
overlay.remove(); overlay.remove();
this._updateMarketUI(); this._updateMarketUI();
} else { Toast.show(data.message || '登录失败', 'error'); } } else { Toast.show(data.message || Lang.t('loginFailed'), 'error'); }
}); });
overlay.querySelector('#ml-register').addEventListener('click', async () => { overlay.querySelector('#ml-register').addEventListener('click', async () => {
const email = document.getElementById('ml-email').value; const email = document.getElementById('ml-email').value;
const pass = document.getElementById('ml-pass').value; const pass = document.getElementById('ml-pass').value;
const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass}) }); const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass}) });
const data = await res.json(); const data = await res.json();
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件', data.error ? 'error' : 'success'); Toast.show(data.error ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), data.error ? 'error' : 'success');
}); });
}, },
@@ -253,11 +253,11 @@ const PageDashboard = {
const uploadBtn = document.getElementById('btn-market-upload'); const uploadBtn = document.getElementById('btn-market-upload');
if (!loginBtn || !uploadBtn) return; if (!loginBtn || !uploadBtn) return;
if (this._marketToken) { if (this._marketToken) {
loginBtn.textContent = '已登录'; loginBtn.textContent = Lang.t('loggedIn');
loginBtn.className = 'btn btn-sm btn-secondary'; loginBtn.className = 'btn btn-sm btn-secondary';
uploadBtn.style.display = ''; uploadBtn.style.display = '';
} else { } else {
loginBtn.textContent = '登录'; loginBtn.textContent = Lang.t('login');
loginBtn.className = 'btn btn-sm btn-primary'; loginBtn.className = 'btn btn-sm btn-primary';
uploadBtn.style.display = 'none'; uploadBtn.style.display = 'none';
} }
@@ -268,7 +268,7 @@ const PageDashboard = {
const url = `${location.origin}/dashboard/${themeId}${gameParam}`; const url = `${location.origin}/dashboard/${themeId}${gameParam}`;
window.open(url, '_blank'); window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
Toast.show('链接已复制,可在局域网设备访问', 'success'); Toast.show(Lang.t('linkCopied'), 'success');
}).catch(() => { }).catch(() => {
Toast.show(`打开地址: ${url}`, 'info'); Toast.show(`打开地址: ${url}`, 'info');
}); });
@@ -279,7 +279,7 @@ const PageDashboard = {
a.href = `/api/dashboards/${themeId}/export`; a.href = `/api/dashboards/${themeId}/export`;
a.download = `${themeId}.tsd`; a.download = `${themeId}.tsd`;
a.click(); a.click();
Toast.show('正在下载 .tsd 文件...', 'info'); Toast.show(Lang.t('exportTsdHint'), 'info');
}, },
_themeCardHtml(theme) { _themeCardHtml(theme) {
@@ -287,7 +287,7 @@ const PageDashboard = {
const aspect = theme.aspect_ratio || 'auto'; const aspect = theme.aspect_ratio || 'auto';
const supported = theme.supported_games || 'all'; const supported = theme.supported_games || 'all';
const isAll = supported === 'all'; const isAll = supported === 'all';
const compatLabel = isAll ? '全' : '限'; const compatLabel = isAll ? Lang.t('fullCompatible') : Lang.t('limitedCompatible');
const compatClass = isAll ? 'badge-info' : 'badge-warning'; const compatClass = isAll ? 'badge-info' : 'badge-warning';
const preview = theme.preview_ext ? `/dashboard/${theme.id}/preview.${theme.preview_ext}` : ''; const preview = theme.preview_ext ? `/dashboard/${theme.id}/preview.${theme.preview_ext}` : '';
const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : ''; const bgStyle = preview ? `background-image:url(${preview});background-size:cover;background-position:center` : '';
@@ -309,7 +309,7 @@ const PageDashboard = {
}, },
async _createNew() { async _createNew() {
const name = prompt('仪表盘名称:', 'My Dashboard'); const name = prompt(Lang.t('dashboardNamePrompt'), 'My Dashboard');
if (!name) return; if (!name) return;
try { try {
const html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>TurboSu - ' + name + '</title><style>*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;font-family: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}</style></head><body><div id="root"><div id="box" class="contain"><div data-bind="speed_kmh" style="font-size:80px;font-weight:200">0</div></div></div><script>const M={aspect_ratio:"auto",render_mode:"contain"};(function(){const b=document.getElementById("box"),p=location.protocol==="https:"?"wss:":"ws:";let w,r;function a(){const rt=M.aspect_ratio==="auto"?null:(c=>{const p=c.split(":");return p.length===2?+p[0]/+p[1]:null})(M.aspect_ratio),md=M.render_mode||"contain";if(!rt){b.style.width="100vw";b.style.height="100vh";b.className="fill";return}const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;if(md==="cover"){cw=vw;ch=vw/rt;b.className="cover"}else{b.className="contain";cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}b.style.width=cw+"px";b.style.height=ch+"px"}function c(){w=new WebSocket(p+"//"+location.host+"/ws");w.onopen=()=>{if(r){clearTimeout(r);r=null}};w.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==="telemetry")document.querySelectorAll("[data-bind]").forEach(el=>{const v=m.data[el.getAttribute("data-bind")];if(v!==undefined)el.textContent=typeof v==="number"?v.toFixed(1):v})}catch(e){}};w.onclose=()=>{r=setTimeout(c,2000)}}a();window.addEventListener("resize",a);c()})();<\/script></body></html>'; const html = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>TurboSu - ' + name + '</title><style>*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}html,body{width:100%;height:100%;overflow:hidden;background:#0a0a0f;color:#fff;font-family: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}</style></head><body><div id="root"><div id="box" class="contain"><div data-bind="speed_kmh" style="font-size:80px;font-weight:200">0</div></div></div><script>const M={aspect_ratio:"auto",render_mode:"contain"};(function(){const b=document.getElementById("box"),p=location.protocol==="https:"?"wss:":"ws:";let w,r;function a(){const rt=M.aspect_ratio==="auto"?null:(c=>{const p=c.split(":");return p.length===2?+p[0]/+p[1]:null})(M.aspect_ratio),md=M.render_mode||"contain";if(!rt){b.style.width="100vw";b.style.height="100vh";b.className="fill";return}const vw=window.innerWidth,vh=window.innerHeight,vr=vw/vh;let cw,ch;if(md==="cover"){cw=vw;ch=vw/rt;b.className="cover"}else{b.className="contain";cw=vr>rt?vh*rt:vw;ch=vr>rt?vh:vw/rt}b.style.width=cw+"px";b.style.height=ch+"px"}function c(){w=new WebSocket(p+"//"+location.host+"/ws");w.onopen=()=>{if(r){clearTimeout(r);r=null}};w.onmessage=(e)=>{try{const m=JSON.parse(e.data);if(m.type==="telemetry")document.querySelectorAll("[data-bind]").forEach(el=>{const v=m.data[el.getAttribute("data-bind")];if(v!==undefined)el.textContent=typeof v==="number"?v.toFixed(1):v})}catch(e){}};w.onclose=()=>{r=setTimeout(c,2000)}}a();window.addEventListener("resize",a);c()})();<\/script></body></html>';
@@ -318,9 +318,9 @@ const PageDashboard = {
body: JSON.stringify({name, category: 'custom', author: 'Local', config: {icon: '📊', aspect_ratio: 'auto', render_mode: 'contain'}, index_html: html}) body: JSON.stringify({name, category: 'custom', author: 'Local', config: {icon: '📊', aspect_ratio: 'auto', render_mode: 'contain'}, index_html: html})
}); });
const data = await res.json(); const data = await res.json();
if (data.id) { Toast.show('创建成功', 'success'); this._loadThemes(); } if (data.id) { Toast.show(Lang.t('createSuccess'), 'success'); this._loadThemes(); }
else Toast.show('创建失败', 'error'); else Toast.show(Lang.t('createFailed'), 'error');
} catch (e) { Toast.show('创建失败', 'error'); } } catch (e) { Toast.show(Lang.t('createFailed'), 'error'); }
}, },
_template() { _template() {
@@ -329,17 +329,17 @@ const PageDashboard = {
<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;display:flex;flex-direction:column;gap:4px"> <div style="padding:8px 12px;margin-top:auto;display:flex;flex-direction:column;gap:4px">
<button id="btn-new-dashboard" class="btn btn-sm btn-primary" style="width:100%;">+ 新建</button> <button id="btn-new-dashboard" class="btn btn-sm btn-primary" style="width:100%;">${Lang.t('newDashboard')}</button>
<button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">导入 .tsd</button> <button id="btn-import-dashboard" class="btn btn-sm btn-secondary" style="width:100%;">${Lang.t('importTsd')}</button>
</div> </div>
</div> </div>
<div class="dashboard-main"> <div class="dashboard-main">
<div id="dash-search-box" style="display:flex;gap:8px;margin-bottom:12px;align-items:center"> <div id="dash-search-box" style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
<input id="dash-search" type="text" placeholder="搜索仪表盘..." style="flex:1;max-width:300px"> <input id="dash-search" type="text" placeholder="${Lang.t('searchDashboards')}" style="flex:1;max-width:300px">
<select id="dash-sort" style="width:120px"> <select id="dash-sort" style="width:120px">
<option value="name">名称</option> <option value="name">${Lang.t('sortByName')}</option>
<option value="category">分类</option> <option value="category">${Lang.t('sortByCategory')}</option>
<option value="author">作者</option> <option value="author">${Lang.t('sortByAuthor')}</option>
</select> </select>
</div> </div>
<div id="dash-theme-grid" class="theme-grid"></div> <div id="dash-theme-grid" class="theme-grid"></div>
+11 -11
View File
@@ -27,7 +27,7 @@ const PageDebug = {
document.getElementById('debug-pause-btn')?.addEventListener('click', () => { document.getElementById('debug-pause-btn')?.addEventListener('click', () => {
this._paused = !this._paused; this._paused = !this._paused;
const btn = document.getElementById('debug-pause-btn'); const btn = document.getElementById('debug-pause-btn');
btn.textContent = this._paused ? '▶ 继续' : '⏸ 暂停'; btn.textContent = this._paused ? Lang.t('resume') : Lang.t('pause');
}); });
document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => { document.getElementById('debug-autoscroll')?.addEventListener('change', (e) => {
@@ -73,7 +73,7 @@ const PageDebug = {
<div class="debug-field-name">${k}</div> <div class="debug-field-name">${k}</div>
<div class="debug-field-value">${displayVal}</div> <div class="debug-field-value">${displayVal}</div>
</div>`; </div>`;
}).join('') || '<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div>'; }).join('') || `<div style="color:var(--text-tertiary);padding:20px;">${Lang.t('waitingData')}</div>`;
}, },
_renderRaw() { _renderRaw() {
@@ -86,7 +86,7 @@ const PageDebug = {
return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`; return `<span style="color:var(--text-tertiary)">[${ts}]</span> <span style="color:var(--accent)">→</span> ${preview}`;
}); });
el.innerHTML = lines.join('\n') || '等待数据...'; el.innerHTML = lines.join('\n') || Lang.t('waitingData');
if (this._autoScroll) { if (this._autoScroll) {
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
@@ -101,24 +101,24 @@ const PageDebug = {
return ` return `
<div class="debug-container"> <div class="debug-container">
<div class="debug-toolbar animated"> <div class="debug-toolbar animated">
<h2 style="font-size:20px;font-weight:700;">数据测试</h2> <h2 style="font-size:20px;font-weight:700;">${Lang.t('debugPage')}</h2>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button id="debug-pause-btn" class="btn btn-sm btn-secondary">⏸ 暂停</button> <button id="debug-pause-btn" class="btn btn-sm btn-secondary">${Lang.t('pause')}</button>
<button id="debug-clear-btn" class="btn btn-sm btn-secondary">清空</button> <button id="debug-clear-btn" class="btn btn-sm btn-secondary">${Lang.t('clear')}</button>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);"> <label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);">
<input type="checkbox" id="debug-autoscroll" checked> 自动滚动 <input type="checkbox" id="debug-autoscroll" checked> ${Lang.t('autoScroll')}
</label> </label>
<button id="debug-show-log" class="btn btn-sm btn-secondary">📄 日志</button> <button id="debug-show-log" class="btn btn-sm btn-secondary">${Lang.t('viewLog')}</button>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:1;min-height:0;">
<div style="overflow-y:auto;"> <div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">解析后的数据字段</h4> <h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">${Lang.t('parsedFields')}</h4>
<div id="debug-fields"> <div id="debug-fields">
<div style="color:var(--text-tertiary);padding:20px;">等待数据...</div> <div style="color:var(--text-tertiary);padding:20px;">${Lang.t('waitingData')}</div>
</div> </div>
</div> </div>
<div style="overflow-y:auto;"> <div style="overflow-y:auto;">
<h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">原始 JSON 数据流</h4> <h4 style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">${Lang.t('rawJsonStream')}</h4>
<div id="debug-raw" class="debug-data"></div> <div id="debug-raw" class="debug-data"></div>
</div> </div>
</div> </div>
+19 -19
View File
@@ -15,25 +15,25 @@ const PageHome = {
async _refreshCards() { async _refreshCards() {
try { try {
const status = await API.getStatus(); const status = await API.getStatus();
this._updateCard('card-server', status.server_running ? '运行中' : '已停止', status.server_running ? 'success' : 'danger'); this._updateCard('card-server', status.server_running ? Lang.t('running') : Lang.t('stopped'), status.server_running ? 'success' : 'danger');
this._updateCard('card-telemetry', status.telemetry_running ? '监听中' : '未启动', status.telemetry_running ? 'success' : 'warning'); this._updateCard('card-telemetry', status.telemetry_running ? Lang.t('listening') : Lang.t('notStarted'), status.telemetry_running ? 'success' : 'warning');
this._updateCard('card-game', status.selected_game_id || '未选择', 'info'); this._updateCard('card-game', status.selected_game_id || Lang.t('notSelected'), 'info');
this._updateCard('card-connections', `${status.ws_clients} 个客户端`, status.ws_clients > 0 ? 'success' : 'secondary'); this._updateCard('card-connections', Lang.t('connectionsCount').replace('%d', status.ws_clients), status.ws_clients > 0 ? 'success' : 'secondary');
this._updateCard('card-packets', `${status.packet_count}`, status.packet_count > 0 ? 'success' : 'secondary'); this._updateCard('card-packets', Lang.t('packetsCount').replace('%d', status.packet_count), status.packet_count > 0 ? 'success' : 'secondary');
const lastTime = status.last_packet_time; const lastTime = status.last_packet_time;
if (lastTime > 0) { if (lastTime > 0) {
const ago = Math.round((Date.now() / 1000) - lastTime); const ago = Math.round((Date.now() / 1000) - lastTime);
this._updateCard('card-last-packet', `${ago}秒前`, ago < 5 ? 'success' : 'warning'); this._updateCard('card-last-packet', Lang.t('secondsAgo').replace('%d', ago), ago < 5 ? 'success' : 'warning');
} else { } else {
this._updateCard('card-last-packet', '暂无数据', 'secondary'); this._updateCard('card-last-packet', Lang.t('noData'), 'secondary');
} }
const td = status.latest_data; const td = status.latest_data;
if (td) { if (td) {
this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary'); this._updateCard('card-speed', `${(td.speed_kmh || 0).toFixed(1)} km/h`, 'primary');
this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary'); this._updateCard('card-rpm', `${(td.rpm || 0).toFixed(0)} RPM`, 'primary');
this._updateCard('card-gear', `档位 ${td.gear || 'N'}`, 'primary'); this._updateCard('card-gear', Lang.t('gearLabel').replace('%s', td.gear || 'N'), 'primary');
} }
} catch (e) { } catch (e) {
console.error('Failed to refresh status:', e); console.error('Failed to refresh status:', e);
@@ -66,21 +66,21 @@ const PageHome = {
_template() { _template() {
return ` return `
<div class="animated"> <div class="animated">
<h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">系统概览</h2> <h2 style="font-size:24px;font-weight:700;margin-bottom:8px;">${Lang.t('systemOverview')}</h2>
<p style="color:var(--text-secondary);margin-bottom:24px;">实时监控 TurboSu 运行状态与游戏遥测连接</p> <p style="color:var(--text-secondary);margin-bottom:24px;">${Lang.t('overviewDesc')}</p>
</div> </div>
<div class="status-grid animated" style="animation-delay:0.1s"> <div class="status-grid animated" style="animation-delay:0.1s">
${this._cardHtml('card-speed', '实时速度', '-- km/h', 'primary')} ${this._cardHtml('card-speed', Lang.t('liveSpeed'), '-- km/h', 'primary')}
${this._cardHtml('card-rpm', '实时转速', '-- RPM', 'primary')} ${this._cardHtml('card-rpm', Lang.t('liveRpm'), '-- RPM', 'primary')}
${this._cardHtml('card-gear', '当前档位', '--', 'primary')} ${this._cardHtml('card-gear', Lang.t('liveGear'), '--', 'primary')}
</div> </div>
<div class="status-grid animated" style="animation-delay:0.15s"> <div class="status-grid animated" style="animation-delay:0.15s">
${this._cardHtml('card-server', '服务器状态', '运行中', 'success')} ${this._cardHtml('card-server', Lang.t('serverStatus'), Lang.t('running'), 'success')}
${this._cardHtml('card-telemetry', '遥测监听', '未启动', 'warning')} ${this._cardHtml('card-telemetry', Lang.t('telemetryStatus'), Lang.t('notStarted'), 'warning')}
${this._cardHtml('card-connections', 'WebSocket 连接', '0 个客户端', 'secondary')} ${this._cardHtml('card-connections', Lang.t('wsConnections'), Lang.t('connectionsCount').replace('%d', 0), 'secondary')}
${this._cardHtml('card-packets', '数据包接收', '0 包', 'secondary')} ${this._cardHtml('card-packets', Lang.t('packetCount'), Lang.t('packetsCount').replace('%d', 0), 'secondary')}
${this._cardHtml('card-last-packet', '最后数据包', '暂无数据', 'secondary')} ${this._cardHtml('card-last-packet', Lang.t('lastPacket'), Lang.t('noData'), 'secondary')}
${this._cardHtml('card-game', '当前游戏', '未选择', 'info')} ${this._cardHtml('card-game', Lang.t('currentGame'), Lang.t('notSelected'), 'info')}
</div>`; </div>`;
}, },
+87 -86
View File
@@ -19,11 +19,11 @@ const PageMarket = {
const grid = document.getElementById('market-grid'); const grid = document.getElementById('market-grid');
if (!grid) return; if (!grid) return;
grid.style.display = 'grid'; grid.style.display = 'grid';
grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">加载中...</div>'; grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-tertiary);">' + Lang.t('loading') + '</div>';
if (this._tab === 'mine') { if (this._tab === 'mine') {
if (!this._marketToken) { if (!this._marketToken) {
grid.innerHTML = this._emptyHtml('请先登录'); grid.innerHTML = this._emptyHtml(Lang.t('needLogin'));
return; return;
} }
return this._loadMine(grid); return this._loadMine(grid);
@@ -48,12 +48,12 @@ const PageMarket = {
} }
} catch(e) { console.error('load local:', e); } } catch(e) { console.error('load local:', e); }
if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? '暂无场景' : '暂无仪表盘'); return; } if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? Lang.t('noScenesMarket') : Lang.t('noDashboardsMarket')); return; }
grid.innerHTML = items.map(d => { grid.innerHTML = items.map(d => {
const installed = d.manifest_id && localIds.includes(d.manifest_id); const installed = d.manifest_id && localIds.includes(d.manifest_id);
return this._cardHtml(d, isScene, installed); return this._cardHtml(d, isScene, installed);
}).join(''); }).join('');
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\'btn-refresh-market\').click();return false" style="color:var(--accent)">重试</a></div>'; } } catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\'btn-refresh-market\').click();return false" style="color:var(--accent)">' + Lang.t('retry') + '</a></div>'; }
}, },
async _loadMine(grid) { async _loadMine(grid) {
@@ -62,27 +62,27 @@ const PageMarket = {
const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken)); const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken));
const data = await res.json(); const data = await res.json();
if (!data.dashboards?.length && !data.scenes?.length) { if (!data.dashboards?.length && !data.scenes?.length) {
grid.innerHTML = this._emptyHtml('你还没有上传任何内容'); grid.innerHTML = this._emptyHtml(Lang.t('noUploads'));
return; return;
} }
let html = ''; let html = '';
if (data.dashboards?.length) { if (data.dashboards?.length) {
html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">我的仪表盘</h3>' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join(''); html += '<h3 style="margin-bottom:10px;font-size:16px;color:var(--text-secondary)">' + Lang.t('myDashboards') + '</h3>' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join('');
} }
if (data.scenes?.length) { if (data.scenes?.length) {
html += '<h3 style="margin:20px 0 10px;font-size:16px;color:var(--text-secondary)">我的场景</h3>' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join(''); html += '<h3 style="margin:20px 0 10px;font-size:16px;color:var(--text-secondary)">' + Lang.t('myScenes') + '</h3>' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join('');
} }
grid.innerHTML = html; grid.innerHTML = html;
} catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败' + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\"btn-refresh-market\").click();return false" style="color:var(--accent)">重试</a></div>'; } } catch (e) { console.error(e); grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' <a href="#" onclick="document.getElementById(\"btn-refresh-market\").click();return false" style="color:var(--accent)">' + Lang.t('retry') + '</a></div>'; }
}, },
_cardHtml(d, isScene, installed) { _cardHtml(d, isScene, installed) {
const icon = d.preview ? '' : (isScene ? '🎬' : (d.icon || '📦')); const icon = d.preview ? '' : (isScene ? '🎬' : (d.icon || '📦'));
const previewUrl = d.preview ? `https://turbosu.yeij.top/api/files/${isScene?'scenes':'dashboards'}/${d.id}/${d.preview}` : ''; const previewUrl = d.preview ? `https://turbosu.yeij.top/api/files/${isScene?'scenes':'dashboards'}/${d.id}/${d.preview}` : '';
const bgStyle = previewUrl ? `background-image:url(${previewUrl});background-size:cover;background-position:center` : ''; const bgStyle = previewUrl ? `background-image:url(${previewUrl});background-size:cover;background-position:center` : '';
const label = installed ? '已安装' : (isScene ? '安装场景' : '安装'); const label = installed ? Lang.t('installed') : (isScene ? Lang.t('installScene') : Lang.t('install'));
const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install'; const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install';
const uninstallBtn = installed ? `<button class="btn btn-sm btn-danger btn-m-uninstall" data-name="${d.name||d.id}" style="margin-top:4px;width:100%">卸载</button>` : ''; const uninstallBtn = installed ? `<button class="btn btn-sm btn-danger btn-m-uninstall" data-name="${d.name||d.id}" style="margin-top:4px;width:100%">` + Lang.t('uninstall') + '</button>' : '';
return `<div class="glass-card theme-card"> return `<div class="glass-card theme-card">
<div class="theme-card-preview" style="${bgStyle}">${icon}</div> <div class="theme-card-preview" style="${bgStyle}">${icon}</div>
<div class="theme-card-info"> <div class="theme-card-info">
@@ -91,7 +91,7 @@ const PageMarket = {
<span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span> <span>${isScene ? (d.game_id||'') : (d.category||'')}</span><span>${isScene ? '' : 'v'+(d.version||'1.0')}</span>
</div> </div>
<div class="theme-card-meta" style="margin-top:4px"> <div class="theme-card-meta" style="margin-top:4px">
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0}</span> <span>${d.author||''}</span><span style="color:var(--text-tertiary)">${Lang.t('downloadCountShort').replace('%d', d.downloads||0)}</span>
</div> </div>
<button class="btn btn-sm btn-primary ${btnClass}" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%" ${installed?'disabled':''}>${label}</button> <button class="btn btn-sm btn-primary ${btnClass}" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%" ${installed?'disabled':''}>${label}</button>
${uninstallBtn} ${uninstallBtn}
@@ -99,18 +99,18 @@ const PageMarket = {
}, },
_myCardHtml(d, collection) { _myCardHtml(d, collection) {
const pending = d.dashb_pending ? '<span class="badge" style="background:rgba(255,159,10,.15);color:#FF9F0A">待审核</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">已通过</span>'; const pending = d.dashb_pending ? '<span class="badge" style="background:rgba(255,159,10,.15);color:#FF9F0A">' + Lang.t('pending') + '</span>' : '<span class="badge" style="background:rgba(52,199,89,.12);color:#34C759">' + Lang.t('approved') + '</span>';
const created = d.created ? new Date(d.created).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-'; const created = d.created ? new Date(d.created).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-';
const updated = d.updated ? new Date(d.updated).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : created; const updated = d.updated ? new Date(d.updated).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : created;
return `<div class="glass-card" style="padding:14px 16px;margin-bottom:8px"> return `<div class="glass-card" style="padding:14px 16px;margin-bottom:8px">
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${d.name||'Unnamed'} <span style="color:var(--text-tertiary);font-weight:400;font-size:12px">v${d.version||'1.0'}</span></div> <div style="font-weight:600;font-size:14px;margin-bottom:4px">${d.name||'Unnamed'} <span style="color:var(--text-tertiary);font-weight:400;font-size:12px">v${d.version||'1.0'}</span></div>
<div style="font-size:12px;color:var(--text-tertiary);margin-bottom:4px"> <div style="font-size:12px;color:var(--text-tertiary);margin-bottom:4px">
${d.author||''} · ${d.downloads||0} 次下载 · ${pending} <br> ${d.author||''} · ${Lang.t('downloadCount').replace('%d', d.downloads||0)} · ${pending} <br>
<span style="font-size:11px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</span> <span style="font-size:11px">上传: ${created}${updated!==created?' · 更新: '+updated:''}</span>
</div> </div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-sm btn-secondary btn-my-update" data-id="${d.id}" data-coll="${collection}" data-file="${d.file||''}" data-preview="${d.preview||''}" data-type="${collection==='scenes'?'.tss':'.tsd'}">更新</button> <button class="btn btn-sm btn-secondary btn-my-update" data-id="${d.id}" data-coll="${collection}" data-file="${d.file||''}" data-preview="${d.preview||''}" data-type="${collection==='scenes'?'.tss':'.tsd'}">${Lang.t('updateItem')}</button>
<button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">删除</button> <button class="btn btn-sm btn-danger btn-my-delete" data-id="${d.id}" data-coll="${collection}">${Lang.t('delete')}</button>
</div></div>`; </div></div>`;
}, },
@@ -126,17 +126,17 @@ const PageMarket = {
if (e.target.closest('.btn-m-uninstall')) { if (e.target.closest('.btn-m-uninstall')) {
const ubtn = e.target.closest('.btn-m-uninstall'); const ubtn = e.target.closest('.btn-m-uninstall');
const uname = ubtn.dataset.name||''; const uname = ubtn.dataset.name||'';
if (!confirm('确定卸载 "' + uname + '"')) return; if (!confirm(Lang.t('confirmUninstall').replace('"$1"', uname))) return;
try { try {
const r = await fetch('/api/dashboards?category=all'); const r = await fetch('/api/dashboards?category=all');
const list = (await r.json()) || []; const list = (await r.json()) || [];
const dash = list.find(d => d.id === uname || d.name === uname); const dash = list.find(d => d.id === uname || d.name === uname);
if (dash) { if (dash) {
await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' }); await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' });
Toast.show('已卸载', 'success'); Toast.show(Lang.t('uninstallSuccess'), 'success');
this._loadItems(); this._loadItems();
} else { Toast.show('未找到本地安装', 'error'); } } else { Toast.show(Lang.t('uninstallNotFound'), 'error'); }
} catch (e) { Toast.show('卸载失败', 'error'); } } catch (e) { Toast.show(Lang.t('uninstallFailed'), 'error'); }
return; return;
} }
@@ -145,31 +145,31 @@ const PageMarket = {
return; return;
} }
if (btnDelete) { if (btnDelete) {
if (!confirm('确定删除?')) return; if (!confirm(Lang.t('confirmDelete'))) return;
try { try {
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) }); await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) });
Toast.show('已删除', 'info'); Toast.show(Lang.t('deleted'), 'info');
this._loadItems(); this._loadItems();
} catch (e) { Toast.show('删除失败', 'error'); } } catch (e) { Toast.show(Lang.t('deleteFailed'), 'error'); }
return; return;
} }
const id = (btnInstall || btnScene)?.dataset.id; const id = (btnInstall || btnScene)?.dataset.id;
const file = (btnInstall || btnScene)?.dataset.file; const file = (btnInstall || btnScene)?.dataset.file;
if (!id || !file) { Toast.show('安装失败:数据不完整', 'error'); return; } if (!id || !file) { Toast.show(Lang.t('installDataIncomplete'), 'error'); return; }
const isScene = !!btnScene; const isScene = !!btnScene;
const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}` const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`
: `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`; : `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`;
try { try {
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
Toast.show('安装成功!请在仪表盘页面查看', 'success', 4000); Toast.show(Lang.t('installedHint'), 'success', 4000);
this._loadItems(); this._loadItems();
} else { } else {
Toast.show('安装失败: ' + (data.error||'请求异常'), 'error'); Toast.show(Lang.t('installFailedMsg') + (data.error||''), 'error');
} }
} catch (e) { Toast.show('安装失败:网络错误', 'error'); } } catch (e) { Toast.show(Lang.t('installNetworkError'), 'error'); }
}); });
document.getElementById('tab-dashboards')?.addEventListener('click', () => { document.getElementById('tab-dashboards')?.addEventListener('click', () => {
this._tab = 'dashboards'; this._tab = 'dashboards';
@@ -200,22 +200,22 @@ const PageMarket = {
const statusEl = document.getElementById('m-status'); const statusEl = document.getElementById('m-status');
if (!loginBtn || !uploadBtn) return; if (!loginBtn || !uploadBtn) return;
if (this._marketToken) { if (this._marketToken) {
loginBtn.textContent = '已登录'; loginBtn.textContent = Lang.t('loggedIn');
loginBtn.className = 'btn btn-sm btn-secondary'; loginBtn.className = 'btn btn-sm btn-secondary';
uploadBtn.style.display = ''; uploadBtn.style.display = '';
if (statusEl) statusEl.textContent = ' 已登录'; if (statusEl) statusEl.textContent = ' ' + Lang.t('loggedIn');
} else { } else {
loginBtn.textContent = '登录'; loginBtn.textContent = Lang.t('login');
loginBtn.className = 'btn btn-sm btn-primary'; loginBtn.className = 'btn btn-sm btn-primary';
uploadBtn.style.display = 'none'; uploadBtn.style.display = 'none';
if (statusEl) statusEl.textContent = ' 未登录'; if (statusEl) statusEl.textContent = ' ' + Lang.t('notLoggedIn');
} }
}, },
_upload() { _upload() {
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; } if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; }
const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">发布作品</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">选择要发布的仪表盘或场景</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle"><span class="switch-track"></span> 上传预览图</label></div><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择文件 (.tsd / .tss)</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">— 或从本地选择 —</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">加载中...</div></div>'; overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">' + Lang.t('publishTitle') + '</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">' + Lang.t('publishDesc') + '</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle"><span class="switch-track"></span> ' + Lang.t('uploadPreview') + '</label></div><button id="pub-file-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">' + Lang.t('chooseFile') + '</button><div style="font-size:13px;color:var(--text-tertiary);margin:10px 0;text-align:center">' + Lang.t('orChooseLocal') + '</div><div id="pub-local-list" style="max-height:300px;overflow-y:auto">' + Lang.t('loading') + '</div></div>';
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
@@ -223,7 +223,7 @@ const PageMarket = {
const previewDrop = document.createElement('div'); const previewDrop = document.createElement('div');
previewDrop.id = 'pub-preview-drop'; previewDrop.id = 'pub-preview-drop';
previewDrop.style.cssText = 'display:none;border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin-top:8px;transition:all .2s'; previewDrop.style.cssText = 'display:none;border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin-top:8px;transition:all .2s';
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)'; previewDrop.textContent = Lang.t('dropPreview');
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn')); overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn'));
function pickPreview() { function pickPreview() {
@@ -231,10 +231,10 @@ const PageMarket = {
inp.onchange = function() { inp.onchange = function() {
const f = this.files[0]; const f = this.files[0];
if (!f) return; if (!f) return;
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
previewFile = f; previewFile = f;
previewDrop.textContent = ' ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)'; previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
}; };
document.body.appendChild(inp); inp.click(); document.body.appendChild(inp); inp.click();
@@ -246,10 +246,10 @@ const PageMarket = {
e.preventDefault(); e.preventDefault();
const f = e.dataTransfer.files[0]; const f = e.dataTransfer.files[0];
if (!f) return; if (!f) return;
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
previewFile = f; previewFile = f;
previewDrop.textContent = ' ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)'; previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)';
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
}); });
previewDrop.addEventListener('click', pickPreview); previewDrop.addEventListener('click', pickPreview);
@@ -260,7 +260,7 @@ const PageMarket = {
} else { } else {
previewFile = null; previewFile = null;
previewDrop.style.display = 'none'; previewDrop.style.display = 'none';
previewDrop.textContent = '点击或拖放图片到此处 (最大 6MB)'; previewDrop.textContent = Lang.t('dropPreview');
previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)';
} }
}); });
@@ -274,7 +274,7 @@ const PageMarket = {
if (previewFile) fd.append('preview', previewFile); if (previewFile) fd.append('preview', previewFile);
const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd }); const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
Toast.show(data.error ? '失败: '+data.error : '发布成功', data.error ? 'error' : 'success'); Toast.show(data.error ? Lang.t('publishFailed') + ': '+data.error : Lang.t('publishSuccess'), data.error ? 'error' : 'success');
if (!data.error) { overlay.remove(); this._loadItems(); } if (!data.error) { overlay.remove(); this._loadItems(); }
}; input.click(); }; input.click();
}); });
@@ -292,27 +292,27 @@ const PageMarket = {
const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.id||'').match(/^[a-f0-9]{6}__/)) : []; const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.id||'').match(/^[a-f0-9]{6}__/)) : [];
let h = ''; let h = '';
if (upD.length) { if (upD.length) {
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地仪表盘 ('+upD.length+')</div>'; h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin-bottom:4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> ' + Lang.t('localDashboards') + ' ('+upD.length+')</div>';
h += '<div class="pub-group">' + upD.map(d => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">'+(d.icon||'📊')+' '+d.name+'</span></div><button class="btn btn-sm btn-primary pub-dash-btn" data-id="'+d.id+'">发布</button></div>').join('') + '</div>'; h += '<div class="pub-group">' + upD.map(d => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">'+(d.icon||'📊')+' '+d.name+'</span></div><button class="btn btn-sm btn-primary pub-dash-btn" data-id="'+d.id+'">' + Lang.t('publish') + '</button></div>').join('') + '</div>';
} }
if (upS.length) { if (upS.length) {
h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin:10px 0 4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> 本地场景 ('+upS.length+')</div>'; h += '<div class="pub-section" style="cursor:pointer;font-weight:600;font-size:13px;color:var(--text-secondary);margin:10px 0 4px;display:flex;align-items:center;gap:6px" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==\'none\'?\'\':\'none\';this.querySelector(\'.pub-arrow\').style.transform=this.nextElementSibling.style.display==\'none\'?\'rotate(-90deg)\':\'\'"><span class="pub-arrow" style="display:inline-block;transition:transform .2s">▼</span> ' + Lang.t('localScenes') + ' ('+upS.length+')</div>';
h += '<div class="pub-group">' + upS.map(s => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">🎬 '+s.name+'</span></div><button class="btn btn-sm btn-primary pub-scene-btn" data-id="'+s.id+'">发布</button></div>').join('') + '</div>'; h += '<div class="pub-group">' + upS.map(s => '<div class="glass-card" style="padding:10px 14px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between;gap:8px"><div><span style="font-weight:500">🎬 '+s.name+'</span></div><button class="btn btn-sm btn-primary pub-scene-btn" data-id="'+s.id+'">' + Lang.t('publish') + '</button></div>').join('') + '</div>';
} }
list.innerHTML = h || '<p style="color:var(--text-tertiary);font-size:12px;padding:10px">没有可发布的内容</p>'; list.innerHTML = h || '<p style="color:var(--text-tertiary);font-size:12px;padding:10px">' + Lang.t('noPublishable') + '</p>';
list.querySelectorAll('.pub-dash-btn').forEach(btn => btn.addEventListener('click', async () => { list.querySelectorAll('.pub-dash-btn').forEach(btn => btn.addEventListener('click', async () => {
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'dashboard',token:this._marketToken})}); const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'dashboard',token:this._marketToken})});
const d = await r.json(); const d = await r.json();
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success'); Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success');
if(!d.error){overlay.remove();this._loadItems();} if(!d.error){overlay.remove();this._loadItems();}
})); }));
list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => { list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => {
const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'scene',token:this._marketToken})}); const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'scene',token:this._marketToken})});
const d = await r.json(); const d = await r.json();
Toast.show(d.error?'失败:'+d.error:'发布成功',d.error?'error':'success'); Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success');
if(!d.error){overlay.remove();this._loadItems();} if(!d.error){overlay.remove();this._loadItems();}
})); }));
} catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">加载失败</p>'; } } catch(e) { list.innerHTML = '<p style="color:var(--text-tertiary);padding:10px">' + Lang.t('loadFailed') + '</p>'; }
}, },
_showLogin() { _showLogin() {
@@ -321,16 +321,16 @@ const PageMarket = {
const isLogin = !this._marketToken; const isLogin = !this._marketToken;
overlay.innerHTML = `<div class="modal" style="min-width:380px;max-width:420px;padding:24px"> overlay.innerHTML = `<div class="modal" style="min-width:380px;max-width:420px;padding:24px">
<div style="display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border-subtle)"> <div style="display:flex;gap:0;margin-bottom:18px;border-bottom:1px solid var(--border-subtle)">
<button id="ml-tab-login" class="btn btn-sm ${isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${isLogin?'var(--accent)':'transparent'};color:${isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">登录</button> <button id="ml-tab-login" class="btn btn-sm ${isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${isLogin?'var(--accent)':'transparent'};color:${isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">${Lang.t('login')}</button>
<button id="ml-tab-register" class="btn btn-sm ${!isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${!isLogin?'var(--accent)':'transparent'};color:${!isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">注册</button> <button id="ml-tab-register" class="btn btn-sm ${!isLogin?'':'btn-secondary'}" style="border-radius:0;border-bottom:2px solid ${!isLogin?'var(--accent)':'transparent'};color:${!isLogin?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px">${Lang.t('register')}</button>
</div> </div>
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email" placeholder="your@email.com"></div> <div class="form-group"><label>${Lang.t('email')}</label><input id="ml-email" type="email" placeholder="your@email.com"></div>
<div class="form-group" id="ml-name-group" style="display:${!isLogin?'':'none'}"><label>用户名</label><input id="ml-name" type="text" placeholder="必填"></div> <div class="form-group" id="ml-name-group" style="display:${!isLogin?'':'none'}"><label>${Lang.t('username')}</label><input id="ml-name" type="text" placeholder="Required"></div>
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div> <div class="form-group"><label>${Lang.t('password')}</label><input id="ml-pass" type="password"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-sm" id="ml-logout" style="display:${this._marketToken?'':'none'};margin-right:auto;background:var(--bg2);color:var(--text2)">退出</button> <button class="btn btn-sm" id="ml-logout" style="display:${this._marketToken?'':'none'};margin-right:auto;background:var(--bg2);color:var(--text2)">${Lang.t('logout')}</button>
<span style="flex:1"></span> <span style="flex:1"></span>
<button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?'登录':'注册'}</button> <button class="btn btn-primary btn-sm" id="ml-submit">${isLogin?Lang.t('login'):Lang.t('register')}</button>
</div></div>`; </div></div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -340,7 +340,7 @@ const PageMarket = {
localStorage.removeItem('turbosu-market-token'); localStorage.removeItem('turbosu-market-token');
overlay.remove(); overlay.remove();
this._updateUI(); this._updateUI();
Toast.show('已退出', 'info'); Toast.show(Lang.t('loggedOut'), 'info');
}); });
} }
@@ -356,7 +356,7 @@ const PageMarket = {
tabLogin.style.cssText = `border-radius:0;border-bottom:2px solid ${isL?'var(--accent)':'transparent'};color:${isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`; tabLogin.style.cssText = `border-radius:0;border-bottom:2px solid ${isL?'var(--accent)':'transparent'};color:${isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
tabRegister.style.cssText = `border-radius:0;border-bottom:2px solid ${!isL?'var(--accent)':'transparent'};color:${!isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`; tabRegister.style.cssText = `border-radius:0;border-bottom:2px solid ${!isL?'var(--accent)':'transparent'};color:${!isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`;
nameGroup.style.display = isL ? 'none' : ''; nameGroup.style.display = isL ? 'none' : '';
submitBtn.textContent = isL ? '登录' : '注册'; submitBtn.textContent = isL ? Lang.t('login') : Lang.t('register');
} }
tabLogin.addEventListener('click', () => setTab('login')); tabLogin.addEventListener('click', () => setTab('login'));
@@ -367,22 +367,22 @@ const PageMarket = {
const doAuth = async () => { const doAuth = async () => {
const email = document.getElementById('ml-email').value; const email = document.getElementById('ml-email').value;
const pass = document.getElementById('ml-pass').value; const pass = document.getElementById('ml-pass').value;
if (!email || !pass) { Toast.show('请输入邮箱和密码', 'error'); return; } if (!email || !pass) { Toast.show(Lang.t('enterEmailPassword'), 'error'); return; }
if (mode === 'login') { if (mode === 'login') {
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) }); const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) });
const data = await res.json(); const data = await res.json();
if (data.token) { if (data.token) {
this._marketToken = data.token; this._marketToken = data.token;
localStorage.setItem('turbosu-market-token', data.token); localStorage.setItem('turbosu-market-token', data.token);
Toast.show('登录成功', 'success'); Toast.show(Lang.t('loginSuccess'), 'success');
overlay.remove(); overlay.remove();
this._updateUI(); this._updateUI();
} else { Toast.show(data.message || '登录失败', 'error'); } } else { Toast.show(data.message || Lang.t('loginFailed'), 'error'); }
} else { } else {
const name = document.getElementById('ml-name')?.value || ''; const name = document.getElementById('ml-name')?.value || '';
const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass, name}) }); const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass, name}) });
const data = await res.json(); const data = await res.json();
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件(点击关闭)', data.error ? 'error' : 'success', data.error ? 3000 : 0); Toast.show(data.error ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), data.error ? 'error' : 'success', data.error ? 3000 : 0);
if (!data.error) overlay.remove(); if (!data.error) overlay.remove();
} }
}; };
@@ -392,30 +392,31 @@ const PageMarket = {
}, },
_emptyHtml(msg) { _emptyHtml(msg) {
return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||'暂无'}</h4></div>`; return `<div class="empty-state" style="grid-column:1/-1"><h4>${msg||Lang.t('marketNoItems')}</h4></div>`;
}, },
_showUpdateModal(ds) { _showUpdateModal(ds) {
if (!this._marketToken) { Toast.show('请先登录', 'error'); return; } if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; }
const isScene = ds.coll === 'scenes'; const isScene = ds.coll === 'scenes';
const typeLabel = isScene ? Lang.t('scene') : Lang.t('dashboard');
const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const overlay = document.createElement('div'); overlay.className = 'modal-overlay';
overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">更新 '+(isScene?'场景':'仪表盘')+'</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">重新上传包文件或更换预览图</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle" checked><span class="switch-track"></span> 更换预览图</label></div><button id="pub-update-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择新文件 ('+(isScene?'.tss':'.tsd')+')</button><div style="font-size:13px;color:var(--text-tertiary);margin-top:10px;text-align:center">注:仅更新预览图可不选文件</div></div>'; overlay.innerHTML = '<div class="modal" style="min-width:480px;max-width:520px;padding:24px"><h3 style="font-size:18px;font-weight:600;margin-bottom:4px">' + Lang.t('updateItem') + ' ' + typeLabel + '</h3><p style="font-size:12px;color:var(--text-secondary);margin-bottom:18px">重新上传包文件或更换预览图</p><div style="margin-bottom:12px"><label class="switch-label" style="font-size:13px"><input type="checkbox" id="pub-preview-toggle" checked><span class="switch-track"></span> 更换预览图</label></div><button id="pub-update-btn" class="btn btn-primary btn-sm" style="width:100%;margin-bottom:10px">📁 选择新文件 ('+(isScene?'.tss':'.tsd')+')</button><div style="font-size:13px;color:var(--text-tertiary);margin-top:10px;text-align:center">注:仅更新预览图可不选文件</div></div>';
document.body.appendChild(overlay); document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
let updateFile = null, previewFile = null; let updateFile = null, previewFile = null;
const previewDrop = document.createElement('div'); const previewDrop = document.createElement('div');
previewDrop.style.cssText = 'border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin:8px 0;transition:all .2s'; previewDrop.style.cssText = 'border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin:8px 0;transition:all .2s';
previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)'; previewDrop.textContent = Lang.t('dropPreview');
overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn')); overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn'));
function pickPreview() { function pickPreview() {
const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none'; const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none';
inp.onchange = function() { inp.onchange = function() {
const f = this.files[0]; if (!f) return; const f = this.files[0]; if (!f) return;
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
previewFile = f; previewDrop.textContent = ' '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)'; previewFile = f; previewDrop.textContent = '\u2713 '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
}; };
document.body.appendChild(inp); inp.click(); document.body.appendChild(inp); inp.click();
@@ -424,27 +425,27 @@ const PageMarket = {
['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; })); ['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; }));
previewDrop.addEventListener('drop', function(e) { previewDrop.addEventListener('drop', function(e) {
e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return; e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return;
if (!f.type.startsWith('image/')) { Toast.show('仅支持图片格式', 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; }
if (f.size > 6291456) { Toast.show('预览图不能超过 6MB', 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; }
previewFile = f; previewDrop.textContent = ' '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)'; previewFile = f; previewDrop.textContent = '\u2713 '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)';
previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)';
}); });
previewDrop.addEventListener('click', pickPreview); previewDrop.addEventListener('click', pickPreview);
overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() { overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() {
previewDrop.style.display = this.checked ? '' : 'none'; previewDrop.style.display = this.checked ? '' : 'none';
if (!this.checked) { previewFile = null; previewDrop.textContent = '点击或拖放新预览图 (最大 6MB)'; previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; } if (!this.checked) { previewFile = null; previewDrop.textContent = Lang.t('dropPreview'); previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; }
}); });
overlay.querySelector('#pub-update-btn').addEventListener('click', () => { overlay.querySelector('#pub-update-btn').addEventListener('click', () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd'; const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd';
input.onchange = function(e) { updateFile = e.target.files[0]; if (updateFile) { const btn = overlay.querySelector('#pub-update-btn'); btn.textContent = ' '+updateFile.name; btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; } }; input.onchange = function(e) { updateFile = e.target.files[0]; if (updateFile) { const btn = overlay.querySelector('#pub-update-btn'); btn.textContent = '\u2713 '+updateFile.name; btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; } };
input.click(); input.click();
}); });
overlay.querySelector('#pub-update-btn').insertAdjacentHTML('afterend', '<button id="pub-do-update" class="btn btn-primary btn-sm" style="width:100%;margin-top:10px">确认更新</button>'); overlay.querySelector('#pub-update-btn').insertAdjacentHTML('afterend', '<button id="pub-do-update" class="btn btn-primary btn-sm" style="width:100%;margin-top:10px">' + Lang.t('confirmUpdate') + '</button>');
overlay.querySelector('#pub-do-update').addEventListener('click', async () => { overlay.querySelector('#pub-do-update').addEventListener('click', async () => {
if (!updateFile && !previewFile) { Toast.show('请选择文件或预览图', 'error'); return; } if (!updateFile && !previewFile) { Toast.show(Lang.t('selectFileOrPreview'), 'error'); return; }
const fd = new FormData(); fd.append('token', this._marketToken); const fd = new FormData(); fd.append('token', this._marketToken);
if (updateFile) fd.append('file', updateFile); if (updateFile) fd.append('file', updateFile);
if (previewFile) fd.append('preview', previewFile); if (previewFile) fd.append('preview', previewFile);
@@ -465,7 +466,7 @@ const PageMarket = {
// Delete old record // Delete old record
await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: ds.id, collection: ds.coll, token: this._marketToken}) }); await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: ds.id, collection: ds.coll, token: this._marketToken}) });
} }
Toast.show(data.error ? '更新失败: '+data.error : '更新成功', data.error ? 'error' : 'success'); Toast.show(data.error ? Lang.t('updateFailed') + data.error : Lang.t('updated'), data.error ? 'error' : 'success');
if (!data.error) { overlay.remove(); this._loadItems(); } if (!data.error) { overlay.remove(); this._loadItems(); }
}); });
}, },
@@ -474,20 +475,20 @@ const PageMarket = {
return `<div class="animated"> return `<div class="animated">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px">
<div> <div>
<h2 style="font-size:24px;font-weight:700">仪表盘市场</h2> <h2 style="font-size:24px;font-weight:700">${Lang.t('marketPage')}</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">浏览并安装社区分享的仪表盘和场景 <span id="m-status" style="color:var(--text-tertiary)"> 未登录</span></p> <p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${Lang.t('marketDesc')} <span id="m-status" style="color:var(--text-tertiary)"> ${Lang.t('notLoggedIn')}</span></p>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">发布你的作品</button> <button id="btn-m-upload" class="btn btn-sm btn-secondary" style="display:none">${Lang.t('publish')}</button>
<button id="btn-m-login" class="btn btn-sm btn-primary">登录</button> <button id="btn-m-login" class="btn btn-sm btn-primary">${Lang.t('login')}</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle);align-items:flex-end"> <div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle);align-items:flex-end">
<button id="tab-dashboards" class="btn btn-sm active" style="border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px">仪表盘</button> <button id="tab-dashboards" class="btn btn-sm active" style="border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px">${Lang.t('dashboardsTab')}</button>
<button id="tab-scenes" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">场景</button> <button id="tab-scenes" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">${Lang.t('scenesTab')}</button>
<button id="tab-mine" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">我的</button> <button id="tab-mine" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">${Lang.t('myTab')}</button>
<span style="flex:1"></span> <span style="flex:1"></span>
<button id="btn-refresh-market" class="icon-btn" style="width:auto;padding:0 8px;margin-bottom:4px;font-size:12px" title="刷新">🔄</button> <button id="btn-refresh-market" class="icon-btn" style="width:auto;padding:0 8px;margin-bottom:4px;font-size:12px" title="${Lang.t('refresh')}">🔄</button>
</div> </div>
<div id="market-grid" class="theme-grid"></div> <div id="market-grid" class="theme-grid"></div>
</div>`; </div>`;
+10 -10
View File
@@ -15,10 +15,10 @@ const PageRecord = {
const list = await res.json(); const list = await res.json();
const el = document.getElementById('rec-file-list'); const el = document.getElementById('rec-file-list');
if (!el) return; if (!el) return;
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:13px;padding:20px">暂无录制文件</p>'; return; } if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:13px;padding:20px">' + Lang.t('noRecordings') + '</p>'; return; }
el.innerHTML = list.map(f => { el.innerHTML = list.map(f => {
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB'; const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
return '<div class="glass-card" style="padding:10px 14px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap"><span style="font-size:12px;flex:1;min-width:150px">' + f.name + '</span><span style="font-size:11px;color:var(--text-tertiary)">' + size + '</span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button><a class="btn btn-sm btn-secondary" href="/api/recording/' + f.name + '/export" download>导出</a><button class="btn btn-sm btn-danger btn-del-rec" data-file="' + f.name + '">删除</button></div>'; return '<div class="glass-card" style="padding:10px 14px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap"><span style="font-size:12px;flex:1;min-width:150px">' + f.name + '</span><span style="font-size:11px;color:var(--text-tertiary)">' + size + '</span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">' + Lang.t('play') + '</button><a class="btn btn-sm btn-secondary" href="/api/recording/' + f.name + '/export" download>' + Lang.t('export') + '</a><button class="btn btn-sm btn-danger btn-del-rec" data-file="' + f.name + '">' + Lang.t('delete') + '</button></div>';
}).join(''); }).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
}, },
@@ -42,14 +42,14 @@ const PageRecord = {
const file = e.target.files[0]; if (!file) return; const file = e.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
await fetch('/api/recording/import', { method: 'POST', body: fd }); await fetch('/api/recording/import', { method: 'POST', body: fd });
Toast.show('导入成功', 'success'); this._loadRecordings(); Toast.show(Lang.t('importSuccess'), 'success'); this._loadRecordings();
}; input.click(); }; input.click();
}); });
document.getElementById('rec-file-list')?.addEventListener('click', async (e) => { document.getElementById('rec-file-list')?.addEventListener('click', async (e) => {
const playBtn = e.target.closest('.btn-play-rec'); const playBtn = e.target.closest('.btn-play-rec');
const delBtn = e.target.closest('.btn-del-rec'); const delBtn = e.target.closest('.btn-del-rec');
if (playBtn) this._playRecording(playBtn.dataset.file); if (playBtn) this._playRecording(playBtn.dataset.file);
if (delBtn && confirm('删除?')) { if (delBtn && confirm(Lang.t('confirmDelete'))) {
await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' }); await fetch('/api/recording/' + delBtn.dataset.file + '/delete', { method: 'DELETE' });
this._loadRecordings(); this._loadRecordings();
} }
@@ -63,24 +63,24 @@ const PageRecord = {
_updateRecUI(recording) { _updateRecUI(recording) {
document.getElementById('btn-rec-start').style.display = recording ? 'none' : ''; document.getElementById('btn-rec-start').style.display = recording ? 'none' : '';
document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none'; document.getElementById('btn-rec-stop').style.display = recording ? '' : 'none';
document.getElementById('rec-status').textContent = recording ? '录制中...' : '就绪'; document.getElementById('rec-status').textContent = recording ? Lang.t('recording') : Lang.t('ready');
}, },
async _playRecording(filename) { async _playRecording(filename) {
document.getElementById('playback-file').textContent = filename; document.getElementById('playback-file').textContent = filename;
document.getElementById('playback-bar').style.display = 'flex'; document.getElementById('playback-bar').style.display = 'flex';
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) }); await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename }) });
Toast.show('回放已开始,打开仪表盘查看数据', 'info'); Toast.show(Lang.t('playbackStartedHint'), 'info');
}, },
_template() { _template() {
return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:8px">录制与回放</h2><p style="color:var(--text-secondary);margin-bottom:20px">录制原始 UDP 流到 .tsr 文件,回放调试仪表盘</p>' + return '<div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:8px">' + Lang.t('recordPage') + '</h2><p style="color:var(--text-secondary);margin-bottom:20px">' + Lang.t('recordDesc') + '</p>' +
'<div class="glass-card" style="padding:20px;margin-bottom:20px"><h3 style="font-size:16px;margin-bottom:12px">录制控制</h3><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><button id="btn-rec-start" class="btn btn-primary">开始录制</button><button id="btn-rec-stop" class="btn btn-danger" style="display:none">停止录制</button><span id="rec-status" style="font-size:13px;color:var(--text-secondary)">就绪</span><span style="flex:1"></span><label class="switch-label"><input type="checkbox" id="rec-auto-mode"><span class="switch-track"></span> 自动检测</label></div></div>' + '<div class="glass-card" style="padding:20px;margin-bottom:20px"><h3 style="font-size:16px;margin-bottom:12px">' + Lang.t('recordControl') + '</h3><div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap"><button id="btn-rec-start" class="btn btn-primary">' + Lang.t('startRecord') + '</button><button id="btn-rec-stop" class="btn btn-danger" style="display:none">' + Lang.t('stopRecord') + '</button><span id="rec-status" style="font-size:13px;color:var(--text-secondary)">' + Lang.t('ready') + '</span><span style="flex:1"></span><label class="switch-label"><input type="checkbox" id="rec-auto-mode"><span class="switch-track"></span> ' + Lang.t('autoDetect') + '</label></div></div>' +
'<div id="playback-bar" class="glass-card" style="padding:12px 20px;margin-bottom:20px;display:none;align-items:center;gap:12px;background:var(--accent-container)"><span style="font-size:13px">正在回放: <strong id="playback-file">-</strong></span><span style="flex:1"></span><span style="font-size:12px;color:var(--text-tertiary)">打开仪表盘页面即可看到数据</span><button id="btn-playback-stop" class="btn btn-sm btn-danger">停止</button></div>' + '<div id="playback-bar" class="glass-card" style="padding:12px 20px;margin-bottom:20px;display:none;align-items:center;gap:12px;background:var(--accent-container)"><span style="font-size:13px">' + Lang.t('playbackActive') + '<strong id="playback-file">-</strong></span><span style="flex:1"></span><span style="font-size:12px;color:var(--text-tertiary)">' + Lang.t('playbackHint') + '</span><button id="btn-playback-stop" class="btn btn-sm btn-danger">' + Lang.t('stopPlayback') + '</button></div>' +
'<div class="glass-card" style="padding:20px"><h3 style="font-size:16px;margin-bottom:12px">录制文件</h3><div style="display:flex;gap:8px;margin-bottom:12px"><button id="btn-import-rec" class="btn btn-sm btn-secondary">导入 .tsr</button></div><div id="rec-file-list"></div></div>' + '<div class="glass-card" style="padding:20px"><h3 style="font-size:16px;margin-bottom:12px">' + Lang.t('recordFiles') + '</h3><div style="display:flex;gap:8px;margin-bottom:12px"><button id="btn-import-rec" class="btn btn-sm btn-secondary">' + Lang.t('importTsr') + '</button></div><div id="rec-file-list"></div></div>' +
'</div>'; '</div>';
} }
+23 -23
View File
@@ -38,11 +38,11 @@ const PageScene = {
try { try {
const scenes = await API.getScenes(this._currentGameId); const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) { if (!scenes || scenes.length === 0) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>暂无场景</h4><p>点击"新建场景"创建你的场景</p></div>`; grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h4>${Lang.t('noScenes')}</h4><p>${Lang.t('createFirstScene')}</p></div>`;
return; return;
} }
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join(''); grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; } } catch (e) { grid.innerHTML = '<div class="empty-state"><p>' + Lang.t('loadFailed') + '</p></div>'; }
}, },
async _openEditor(sceneId) { async _openEditor(sceneId) {
@@ -79,7 +79,7 @@ const PageScene = {
<span>${d.icon||'📊'}</span> <span>${d.icon||'📊'}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
</div>` </div>`
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">无兼容仪表盘</p>'; ).join('') || '<p style="color:var(--text-tertiary);font-size:12px">' + Lang.t('noCompatibleDashForScene') + '</p>';
list.querySelectorAll('.editor-dash-item').forEach(el => { list.querySelectorAll('.editor-dash-item').forEach(el => {
el.addEventListener('dragstart', (e) => { el.addEventListener('dragstart', (e) => {
@@ -174,9 +174,9 @@ const PageScene = {
const created = await API.createScene(data); const created = await API.createScene(data);
this._editorSceneId = created.id; this._editorSceneId = created.id;
} }
Toast.show('场景保存成功', 'success'); Toast.show(Lang.t('sceneSaveSuccess'), 'success');
this._showSceneList(); this._showSceneList();
} catch (e) { Toast.show('保存失败: ' + e.message, 'error'); } } catch (e) { Toast.show(Lang.t('saveFailed') + e.message, 'error'); }
}, },
_bindEvents() { _bindEvents() {
@@ -188,8 +188,8 @@ const PageScene = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/scenes/import', { method: 'POST', body: fd }); const res = await fetch('/api/scenes/import', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
if (data.ok) { Toast.show('场景导入成功', 'success'); this._showSceneList(); } if (data.ok) { Toast.show(Lang.t('sceneImportSuccess'), 'success'); this._showSceneList(); }
else Toast.show('导入失败', 'error'); else Toast.show(Lang.t('importFailed'), 'error');
}; input.click(); }; input.click();
}); });
@@ -202,7 +202,7 @@ const PageScene = {
const action = card.dataset.action, sceneId = card.dataset.sceneId; const action = card.dataset.action, sceneId = card.dataset.sceneId;
if (action === 'render') this._renderScene(sceneId); if (action === 'render') this._renderScene(sceneId);
else if (action === 'edit') this._openEditor(sceneId); else if (action === 'edit') this._openEditor(sceneId);
else if (action === 'delete') { if (confirm('确定删除?')) { API.deleteScene(sceneId); this._showSceneList(); } } else if (action === 'delete') { if (confirm(Lang.t('confirmDelete'))) { API.deleteScene(sceneId); this._showSceneList(); } }
else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); } else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); }
}); });
}, },
@@ -210,7 +210,7 @@ const PageScene = {
_renderScene(sceneId) { _renderScene(sceneId) {
const url = `${location.origin}/scene/${sceneId}`; const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank'); window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => Toast.show('链接已复制', 'success')).catch(() => {}); navigator.clipboard.writeText(url).then(() => Toast.show(Lang.t('linkCopiedShort'), 'success')).catch(() => {});
}, },
_sceneCardHtml(scene) { _sceneCardHtml(scene) {
@@ -219,14 +219,14 @@ const PageScene = {
return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render"> return `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''} ${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div> <div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || '未关联游戏'}</div> <div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || Lang.t('noGame')}</div>
<div class="scene-card-meta"><span>${canvasCount} 画布</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div> <div class="scene-card-meta"><span>${Lang.t('canvasesLabel').replace('%d', canvasCount)}</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
<div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div> <div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap"> <div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
<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">${Lang.t('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">${Lang.t('edit')}</button>
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">导出</button> <button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">${Lang.t('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">${Lang.t('delete')}</button>
</div></div>`; </div></div>`;
}, },
@@ -234,12 +234,12 @@ const PageScene = {
return ` return `
<div class="scene-header animated" id="scene-list-container"> <div class="scene-header animated" id="scene-list-container">
<div> <div>
<h2>场景管理</h2> <h2>${Lang.t('sceneManagement')}</h2>
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}</p> <p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? Lang.t('currentGamePrefix') + this._currentGameName : Lang.t('noGameSelected')}</p>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button id="btn-import-scene" class="btn btn-secondary btn-sm">导入 .tss</button> <button id="btn-import-scene" class="btn btn-secondary btn-sm">${Lang.t('importTss')}</button>
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>+ 新建场景</button> <button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>${Lang.t('newScene')}</button>
</div> </div>
</div> </div>
<div id="scene-grid" class="scene-grid animated"></div> <div id="scene-grid" class="scene-grid animated"></div>
@@ -247,14 +247,14 @@ const PageScene = {
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%"> <div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">← 返回</button> <button id="btn-back-to-list" class="btn btn-sm btn-secondary">${Lang.t('backToList')}</button>
<input id="edit-scene-name" placeholder="场景名称" style="font-size:18px;font-weight:600;width:250px" value="New Scene"> <input id="edit-scene-name" placeholder="${Lang.t('sceneName')}" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
</div> </div>
<button id="btn-save-scene" class="btn btn-primary btn-sm">保存场景</button> <button id="btn-save-scene" class="btn btn-primary btn-sm">${Lang.t('saveScene')}</button>
</div> </div>
<div style="display:flex;gap:12px;flex:1;min-height:0"> <div style="display:flex;gap:12px;flex:1;min-height:0">
<div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px"> <div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px">
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">仪表盘列表 (拖到画布)</h4> <h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">${Lang.t('dashListDrag')}</h4>
<div id="editor-dash-list"></div> <div id="editor-dash-list"></div>
</div> </div>
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative"> <div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
+43 -43
View File
@@ -31,18 +31,18 @@ const PageSettings = {
_bindEvents() { _bindEvents() {
document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => { document.getElementById('settings-telemetry-port')?.addEventListener('change', async (e) => {
await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 }); await API.updateConfig({ telemetry_port: parseInt(e.target.value) || 20777 });
Toast.show('端口已更新,重启后生效', 'info'); Toast.show(Lang.t('portUpdated'), 'info');
}); });
document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => { document.getElementById('settings-unified-port')?.addEventListener('change', async (e) => {
await API.updateConfig({ use_unified_port: e.target.checked }); await API.updateConfig({ use_unified_port: e.target.checked });
this._toggleGamePorts(e.target.checked); this._toggleGamePorts(e.target.checked);
Toast.show('已更新', 'info'); Toast.show(Lang.t('configSaved'), 'info');
}); });
document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => { document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => {
await API.stopTelemetry(); await API.startTelemetry(); await API.stopTelemetry(); await API.startTelemetry();
Toast.show('遥测监听已重启', 'success'); Toast.show(Lang.t('telemetryRestarted'), 'success');
}); });
document.getElementById('btn-add-forward')?.addEventListener('click', () => this._addForward()); document.getElementById('btn-add-forward')?.addEventListener('click', () => this._addForward());
@@ -61,13 +61,13 @@ const PageSettings = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/games/install', { method: 'POST', body: fd }); const res = await fetch('/api/games/install', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
if (data.ok) { Toast.show('插件安装成功', 'success'); this.render(); } if (data.ok) { Toast.show(Lang.t('pluginInstalled'), 'success'); this.render(); }
else Toast.show('安装失败', 'error'); else Toast.show(Lang.t('importFailed'), 'error');
}; input.click(); }; input.click();
}); });
document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => { document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => {
await API.reloadGamePlugins(); Toast.show('插件已重新加载', 'success'); this.render(); await API.reloadGamePlugins(); Toast.show(Lang.t('pluginReloaded'), 'success'); this.render();
}); });
document.getElementById('settings-game-list')?.addEventListener('click', async (e) => { document.getElementById('settings-game-list')?.addEventListener('click', async (e) => {
@@ -78,7 +78,7 @@ const PageSettings = {
const a = document.createElement('a'); a.href = '/api/games/' + pid + '/export'; a.download = pid + '.tsp'; a.click(); const a = document.createElement('a'); a.href = '/api/games/' + pid + '/export'; a.download = pid + '.tsp'; a.click();
} }
if (removeBtn) { if (removeBtn) {
if (confirm('移除?')) { await API.removeGamePlugin(removeBtn.dataset.pluginId); Toast.show('已移除', 'info'); this.render(); } if (confirm(Lang.t('confirmRemove'))) { await API.removeGamePlugin(removeBtn.dataset.pluginId); Toast.show(Lang.t('removeSuccess'), 'info'); this.render(); }
} }
}); });
@@ -89,7 +89,7 @@ const PageSettings = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd }); const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
Toast.show(data.ok ? '导入成功' : '导入失败', data.ok ? 'success' : 'error'); Toast.show(data.ok ? Lang.t('importSuccess') : Lang.t('importFailed'), data.ok ? 'success' : 'error');
}; input.click(); }; input.click();
}); });
@@ -107,18 +107,18 @@ const PageSettings = {
const fd = new FormData(); fd.append('file', file); const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/backup/import', { method: 'POST', body: fd }); const res = await fetch('/api/backup/import', { method: 'POST', body: fd });
const data = await res.json(); const data = await res.json();
Toast.show(data.ok ? data.message || '恢复成功' : '恢复失败: ' + data.message, data.ok ? 'success' : 'error'); Toast.show(data.ok ? data.message || Lang.t('restoreSuccess') : Lang.t('restoreFailed') + data.message, data.ok ? 'success' : 'error');
}; input.click(); }; input.click();
}); });
document.getElementById('btn-restart-server')?.addEventListener('click', async () => { document.getElementById('btn-restart-server')?.addEventListener('click', async () => {
if (!confirm('确定重启 TurboSu 服务?')) return; if (!confirm(Lang.t('confirmRestart'))) return;
try { try {
await fetch('/api/restart', { method: 'POST' }); await fetch('/api/restart', { method: 'POST' });
Toast.show('服务正在重启,3秒后自动刷新...', 'info'); Toast.show(Lang.t('restarting'), 'info');
setTimeout(() => location.reload(), 3000); setTimeout(() => location.reload(), 3000);
} catch (e) { } catch (e) {
Toast.show('重启请求已发送,请手动刷新', 'info'); Toast.show(Lang.t('restartRequestSent'), 'info');
setTimeout(() => location.reload(), 3000); setTimeout(() => location.reload(), 3000);
} }
}); });
@@ -138,13 +138,13 @@ const PageSettings = {
await fetch('/api/record/start', { method: 'POST' }); await fetch('/api/record/start', { method: 'POST' });
document.getElementById('btn-rec-start').style.display = 'none'; document.getElementById('btn-rec-start').style.display = 'none';
document.getElementById('btn-rec-stop').style.display = ''; document.getElementById('btn-rec-stop').style.display = '';
document.getElementById('rec-status').textContent = '录制中...'; document.getElementById('rec-status').textContent = Lang.t('recording');
}); });
document.getElementById('btn-rec-stop')?.addEventListener('click', async () => { document.getElementById('btn-rec-stop')?.addEventListener('click', async () => {
await fetch('/api/record/stop', { method: 'POST' }); await fetch('/api/record/stop', { method: 'POST' });
document.getElementById('btn-rec-start').style.display = ''; document.getElementById('btn-rec-start').style.display = '';
document.getElementById('btn-rec-stop').style.display = 'none'; document.getElementById('btn-rec-stop').style.display = 'none';
document.getElementById('rec-status').textContent = '已停止'; document.getElementById('rec-status').textContent = Lang.t('stopped');
this._loadRecordings(); this._loadRecordings();
}); });
document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => { document.getElementById('rec-auto-mode')?.addEventListener('change', async (e) => {
@@ -157,10 +157,10 @@ const PageSettings = {
await API.updateConfig({ ui_test_mode: on }); await API.updateConfig({ ui_test_mode: on });
if (on) { if (on) {
await fetch('/api/test-mode/start', { method: 'POST' }); await fetch('/api/test-mode/start', { method: 'POST' });
Toast.show('测试模式已开启', 'success'); Toast.show(Lang.t('testModeOn'), 'success');
} else { } else {
await fetch('/api/test-mode/stop', { method: 'POST' }); await fetch('/api/test-mode/stop', { method: 'POST' });
Toast.show('测试模式已关闭', 'info'); Toast.show(Lang.t('testModeOff'), 'info');
} }
}); });
@@ -173,7 +173,7 @@ const PageSettings = {
if (id && port > 0) gamePorts[id] = port; if (id && port > 0) gamePorts[id] = port;
}); });
await API.updateConfig({ game_ports: gamePorts }); await API.updateConfig({ game_ports: gamePorts });
Toast.show('端口已保存', 'success'); Toast.show(Lang.t('portsSaved'), 'success');
}); });
}, },
@@ -182,7 +182,7 @@ const PageSettings = {
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'forward-row glass-card'; row.className = 'forward-row glass-card';
row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap'; row.style.cssText = 'padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap';
row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label class="switch-label"><input type="checkbox" class="forward-toggle" checked><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button>'; row.innerHTML = '<input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px"><label class="switch-label"><input type="checkbox" class="forward-toggle" checked><span class="switch-track"></span> ' + Lang.t('forwardEnabled') + '</label><button class="btn btn-sm btn-danger btn-del-forward">x</button>';
list.appendChild(row); list.appendChild(row);
}, },
@@ -196,7 +196,7 @@ const PageSettings = {
const enabled = r.querySelector('.forward-toggle')?.checked !== false; const enabled = r.querySelector('.forward-toggle')?.checked !== false;
if (host && port > 0) targets.push({ host, port, name, enabled }); if (host && port > 0) targets.push({ host, port, name, enabled });
}); });
this._saveForwards(targets).then(() => Toast.show('已保存 ' + targets.length + ' 个转发目标', 'success')); this._saveForwards(targets).then(() => Toast.show(Lang.t('forwardsSaved').replace('%d', targets.length), 'success'));
}, },
async _loadRecordings() { async _loadRecordings() {
@@ -205,15 +205,15 @@ const PageSettings = {
const list = await res.json(); const list = await res.json();
const el = document.getElementById('recording-list'); const el = document.getElementById('recording-list');
if (!el) return; if (!el) return;
if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:12px">暂无录制文件</p>'; return; } if (!list.length) { el.innerHTML = '<p style="color:var(--text-tertiary);font-size:12px">' + Lang.t('noRecordings') + '</p>'; return; }
el.innerHTML = list.map(f => { el.innerHTML = list.map(f => {
const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB'; const size = f.size > 1048576 ? (f.size/1048576).toFixed(1)+'MB' : (f.size/1024).toFixed(1)+'KB';
return '<div class="glass-card" style="padding:8px 12px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">' + f.name + ' <span style="color:var(--text-tertiary)">' + size + '</span></span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">播放</button></div>'; return '<div class="glass-card" style="padding:8px 12px;margin-bottom:4px;display:flex;align-items:center;justify-content:space-between"><span style="font-size:12px">' + f.name + ' <span style="color:var(--text-tertiary)">' + size + '</span></span><button class="btn btn-sm btn-primary btn-play-rec" data-file="' + f.name + '">' + Lang.t('play') + '</button></div>';
}).join(''); }).join('');
el.querySelectorAll('.btn-play-rec').forEach(btn => { el.querySelectorAll('.btn-play-rec').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) }); await fetch('/api/playback/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: btn.dataset.file }) });
Toast.show('回放已开始', 'success'); Toast.show(Lang.t('playbackStarted'), 'success');
}); });
}); });
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@@ -225,51 +225,51 @@ const PageSettings = {
const proto = g.protocol || 'udp'; const proto = g.protocol || 'udp';
const mode = g.mode || (proto === 'tcp' ? 'server' : ''); const mode = g.mode || (proto === 'tcp' ? 'server' : '');
const protoLabel = proto.toUpperCase() + (mode ? '/' + mode : ''); const protoLabel = proto.toUpperCase() + (mode ? '/' + mode : '');
return '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:120px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><span style="width:70px;font-size:11px;color:var(--text-tertiary)">' + protoLabel + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">默认: ' + (g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '</span></div>'; return '<div class="game-port-row" style="display:flex;gap:8px;align-items:center;margin-bottom:6px"><span class="gp-id" style="width:120px;font-size:13px;color:var(--text-secondary)">' + g.name + '</span><span style="width:70px;font-size:11px;color:var(--text-tertiary)">' + protoLabel + '</span><input class="gp-port" type="number" value="' + (gamePorts[g.id] || g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '" min="1024" max="65535" style="width:100px"><span style="font-size:11px;color:var(--text-tertiary)">' + Lang.t('defaultPortLabel') + (g.default_port || (proto==='tcp'?cfg.tcp_port||20778:20777)) + '</span></div>';
}).join(''); }).join('');
}, },
_forwardListHtml(forwards) { _forwardListHtml(forwards) {
if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">暂无转发目标</p>'; if (!forwards || forwards.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;padding:8px 0">' + Lang.t('noForwardTargets') + '</p>';
return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label class="switch-label"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '><span class="switch-track"></span> 启用</label><button class="btn btn-sm btn-danger btn-del-forward">x</button></div>').join(''); return forwards.map(f => '<div class="forward-row glass-card" style="padding:12px;margin-bottom:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><input class="fw-host" placeholder="IP/域名" style="flex:2;min-width:120px" value="' + (f.host||'') + '"><input class="fw-port" type="number" placeholder="端口" style="flex:1;min-width:70px" value="' + (f.port||'') + '" min="1" max="65535"><input class="fw-name" placeholder="名称" style="flex:1;min-width:80px" value="' + (f.name||'') + '"><label class="switch-label"><input type="checkbox" class="forward-toggle" ' + (f.enabled!==false?'checked':'') + '><span class="switch-track"></span> ' + Lang.t('forwardEnabled') + '</label><button class="btn btn-sm btn-danger btn-del-forward">x</button></div>').join('');
}, },
_gamePluginListHtml(games) { _gamePluginListHtml(games) {
if (!games || games.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;">暂无游戏插件</p>'; if (!games || games.length === 0) return '<p style="color:var(--text-tertiary);font-size:13px;">' + Lang.t('noPlugins') + '</p>';
return games.map(g => '<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between"><div><div style="font-weight:600">' + g.name + '</div><div style="font-size:12px;color:var(--text-tertiary)">' + (g.description||'') + ' | v' + g.version + ' | ' + (g.author||'') + '<span class="badge ' + (g.is_builtin?'badge-info':'badge-success') + '" style="margin-left:6px">' + (g.is_builtin?'内置':'社区') + '</span></div></div><div style="display:flex;gap:6px"><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>').join(''); return games.map(g => '<div class="glass-card" style="padding:16px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between"><div><div style="font-weight:600">' + g.name + '</div><div style="font-size:12px;color:var(--text-tertiary)">' + (g.description||'') + ' | v' + g.version + ' | ' + (g.author||'') + '<span class="badge ' + (g.is_builtin?'badge-info':'badge-success') + '" style="margin-left:6px">' + (g.is_builtin ? Lang.t('builtin') : Lang.t('community')) + '</span></div></div><div style="display:flex;gap:6px"><button class="btn btn-sm btn-secondary btn-export-plugin" data-plugin-id="' + g.id + '">' + Lang.t('export') + ' .tsp</button>' + (g.is_builtin?'':'<button class="btn btn-sm btn-danger btn-remove-plugin" data-plugin-id="' + g.id + '">' + Lang.t('remove') + '</button>') + '</div></div>').join('');
}, },
_template(cfg, games, forwards) { _template(cfg, games, forwards) {
const sections = [ const sections = [
{ id: 'sec-forward', label: '数据转发' }, { id: 'sec-forward', label: Lang.t('dataForwarding') },
{ id: 'sec-connection', label: '连接设置' }, { id: 'sec-connection', label: Lang.t('connectionSettings') },
{ id: 'sec-dashboards', label: '仪表盘管理' }, { id: 'sec-dashboards', label: Lang.t('dashManagement') },
{ id: 'sec-plugins', label: '游戏插件' }, { id: 'sec-plugins', label: Lang.t('pluginManagement') },
{ id: 'sec-backup', label: '数据备份' }, { id: 'sec-backup', label: Lang.t('dataBackup') },
{ id: 'sec-dev', label: '开发工具' }, { id: 'sec-dev', label: Lang.t('devTools') },
{ id: 'sec-system', label: '系统' }, { id: 'sec-system', label: Lang.t('systemSection') },
]; ];
return '<div class="settings-layout"><div class="settings-nav">' + return '<div class="settings-layout"><div class="settings-nav">' +
sections.map(s => '<a class="settings-nav-item" data-target="' + s.id + '">' + s.label + '</a>').join('') + sections.map(s => '<a class="settings-nav-item" data-target="' + s.id + '">' + s.label + '</a>').join('') +
'</div><div class="settings-main"><div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:24px">设置</h2>' + '</div><div class="settings-main"><div class="animated"><h2 style="font-size:24px;font-weight:700;margin-bottom:24px">' + Lang.t('settings') + '</h2>' +
'<div class="settings-section" id="sec-forward"><h3>数据转发</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">将收到的游戏原始 UDP 数据包完整转发到下游物理外设</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">+ 添加目标</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">保存并生效</button></div></div>' + '<div class="settings-section" id="sec-forward"><h3>' + Lang.t('dataForwarding') + '</h3><p style="font-size:12px;color:var(--text-tertiary);margin-bottom:12px">' + Lang.t('dataForwardingDesc') + '</p><div id="forward-list">' + this._forwardListHtml(forwards) + '</div><div style="display:flex;gap:8px;margin-top:8px"><button id="btn-add-forward" class="btn btn-sm btn-secondary">' + Lang.t('addTarget') + '</button><button id="btn-save-forwards" class="btn btn-sm btn-primary">' + Lang.t('saveEffective') + '</button></div></div>' +
'<div class="settings-section" id="sec-connection"><h3>连接设置</h3><div class="settings-row"><div><div class="settings-label">遥测监听端口</div><div class="settings-desc">全局统一端口</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">使用游戏独立端口</div><div class="settings-desc">关闭后切游戏自动切换到各游戏的默认端口</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '><span class="switch-track"></span></label></div></div><div id="game-ports-section" style="margin-top:8px;padding:12px;background:var(--bg-tertiary);border-radius:8px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">各游戏独立端口</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">保存端口</button></div><div class="settings-row"><div><div class="settings-label">重启遥测监听</div><div class="settings-desc">手动重启</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">重启监听</button></div></div></div>' + '<div class="settings-section" id="sec-connection"><h3>' + Lang.t('connectionSettings') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('telemetryPort') + '</div><div class="settings-desc">' + Lang.t('telemetryPortDesc') + '</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">' + Lang.t('unifiedPortToggle') + '</div><div class="settings-desc">' + Lang.t('unifiedPortDesc') + '</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-unified-port" ' + (cfg.use_unified_port!==false?'checked':'') + '><span class="switch-track"></span></label></div></div><div id="game-ports-section" style="margin-top:8px;padding:12px;background:var(--bg-tertiary);border-radius:8px"><h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">' + Lang.t('perGamePort') + '</h4>' + this._gamePortListHtml(games, cfg) + '<button id="btn-save-game-ports" class="btn btn-sm btn-primary" style="margin-top:8px">' + Lang.t('savePorts') + '</button></div><div class="settings-row"><div><div class="settings-label">' + Lang.t('restartListener') + '</div><div class="settings-desc">' + Lang.t('restartListenerDesc') + '</div></div><div class="settings-control"><button id="settings-restart-telemetry" class="btn btn-secondary btn-sm">' + Lang.t('restartBtn') + '</button></div></div></div>' +
'<div class="settings-section" id="sec-dashboards"><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" id="sec-dashboards"><h3>' + Lang.t('dashManagement') + '</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-dashboard" class="btn btn-secondary btn-sm">' + Lang.t('importTsd') + '</button><span style="font-size:12px;color:var(--text-tertiary);display:flex;align-items:center">' + Lang.t('rightClickExport') + '</span></div></div>' +
'<div class="settings-section" id="sec-plugins"><h3>游戏插件管理</h3><div style="display:flex;gap:8px;margin-bottom:16px"><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>' + '<div class="settings-section" id="sec-plugins"><h3>' + Lang.t('pluginManagement') + '</h3><div style="display:flex;gap:8px;margin-bottom:16px"><button id="btn-import-plugin" class="btn btn-secondary btn-sm">' + Lang.t('importTsp') + '</button><button id="btn-reload-plugins" class="btn btn-secondary btn-sm">' + Lang.t('reloadPlugins') + '</button></div><div id="settings-game-list">' + this._gamePluginListHtml(games) + '</div></div>' +
'<div class="settings-section" id="sec-backup"><h3>数据备份</h3><div class="settings-row"><div><div class="settings-label">导出全量备份</div><div class="settings-desc">打包仪表盘+场景+配置为 .tsb</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">下载 .tsb</button></div></div><div class="settings-row"><div><div class="settings-label">恢复备份</div><div class="settings-desc">从 .tsb 文件恢复</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">导入 .tsb</button></div></div></div>' + '<div class="settings-section" id="sec-backup"><h3>' + Lang.t('dataBackup') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('exportBackup') + '</div><div class="settings-desc">' + Lang.t('backupDesc') + '</div></div><div class="settings-control"><button id="btn-export-backup" class="btn btn-secondary btn-sm">' + Lang.t('downloadTsb') + '</button></div></div><div class="settings-row"><div><div class="settings-label">' + Lang.t('restoreBackup') + '</div><div class="settings-desc">' + Lang.t('restoreBackupDesc') + '</div></div><div class="settings-control"><button id="btn-import-backup" class="btn btn-secondary btn-sm">' + Lang.t('importTsb') + '</button></div></div></div>' +
'<div class="settings-section" id="sec-dev"><h3>开发工具</h3><div class="settings-row"><div><div class="settings-label">UI 测试模式</div><div class="settings-desc">本地随机模拟遥测数据</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '><span class="switch-track"></span></label></div></div></div>' + '<div class="settings-section" id="sec-dev"><h3>' + Lang.t('devTools') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('testMode') + '</div><div class="settings-desc">' + Lang.t('testModeDesc') + '</div></div><div class="settings-control"><label class="switch-label"><input type="checkbox" id="settings-test-mode" ' + (cfg.ui_test_mode?'checked':'') + '><span class="switch-track"></span></label></div></div></div>' +
'<div class="settings-section" id="sec-system"><h3>系统</h3><div class="settings-row"><div><div class="settings-label">重启 TurboSu</div><div class="settings-desc">重新加载所有代码和配置</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">重启服务</button></div></div></div>' + '<div class="settings-section" id="sec-system"><h3>' + Lang.t('systemSection') + '</h3><div class="settings-row"><div><div class="settings-label">' + Lang.t('restartServer') + '</div><div class="settings-desc">' + Lang.t('restartServerDesc') + '</div></div><div class="settings-control"><button id="btn-restart-server" class="btn btn-danger btn-sm">' + Lang.t('restartBtnSys') + '</button></div></div></div>' +
'<div class="settings-section"><h3>关于</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">赛车遥测仪表盘 · 极速中枢 · Yei.J. (AskaEth)<br><a href="https://git.yeij.top/AskaEth/TurboSu" target="_blank" style="font-size:11px;color:var(--accent)">https://git.yeij.top/AskaEth/TurboSu</a></div></div></div></div>' + '<div class="settings-section"><h3>' + Lang.t('about') + '</h3><div class="settings-row"><div><div class="settings-label">TurboSu</div><div class="settings-desc">' + Lang.t('aboutDesc') + '<br><a href="https://git.yeij.top/AskaEth/TurboSu" target="_blank" style="font-size:11px;color:var(--accent)">https://git.yeij.top/AskaEth/TurboSu</a></div></div></div></div>' +
'</div></div></div>'; '</div></div></div>';
} }
+336 -41
View File
@@ -2,50 +2,332 @@ const Lang = {
_current: 'zh', _current: 'zh',
_strings: { _strings: {
zh: { zh: {
home: '首页', dashboard: '仪表盘', scene: '场景', settings: '设置', // Nav
debug: '数据测试', selectGame: '选择游戏', collapsed: '折叠侧边栏', home: '首页', dashboard: '仪表盘', scene: '场景', market: '市场',
connected: '已连接', disconnected: '未连接', speed: '实时速度', rpm: '实时转速', record: '录制回放', debug: '数据测试', settings: '设置',
gear: '当前档位', server: '服务器状态', telemetry: '遥测监听', selectGame: '选择游戏', collapseSidebar: '折叠侧边栏',
connections: 'WebSocket 连接', packets: '数据包接收', lastPacket: '最后数据包',
currentGame: '当前游戏', systemOverview: '系统概览', running: '运行中', // Connection
stopped: '已停止', notStarted: '未启动', noData: '暂无数据', client: '个客户端', connected: '已连接', disconnected: '未连接', connectionError: '连接错误',
notSelected: '未选择', packetsUnit: '包', secAgo: '秒前',
importTsd: '导入 .tsd', importTss: '导入 .tss', importTsp: '导入 .tsp', // Status cards
export: '导出', delete: '删除', edit: '编辑', render: '渲染', save: '保存', serverStatus: '服务器状态', telemetryStatus: '遥测监听',
cancel: '取消', create: '新建场景', sceneName: '场景名称', wsConnections: 'WebSocket 连接', packetCount: '数据包接收',
search: '搜索仪表盘...', sort: '排序', all: '全部', none: '暂无', lastPacket: '最后数据包', currentGame: '当前游戏',
forward: '数据转发', addTarget: '+ 添加目标', saveActive: '保存并生效', liveSpeed: '实时速度', liveRpm: '实时转速', liveGear: '当前档位',
connectionSettings: '连接设置', telemetryPort: '遥测监听端口', systemOverview: '系统概览', overviewDesc: '实时监控 TurboSu 运行状态与游戏遥测连接',
unifiedPort: '统一端口', perGamePort: '各游戏独立端口', running: '运行中', stopped: '已停止', notStarted: '未启动',
restart: '重启监听', pluginSettings: '游戏插件管理', noData: '暂无数据', notSelected: '未选择',
plugins: '游戏插件', reload: '重新加载', backup: '数据备份', listening: '监听中',
exportBackup: '下载 .tsb', importBackup: '导入 .tsb',
// Dashboard
searchDashboards: '搜索仪表盘...', sortByName: '名称', sortByCategory: '分类',
sortByAuthor: '作者', importTsd: '导入 .tsd', newDashboard: '+ 新建',
noDashboards: '暂无仪表盘主题', createFirstDash: '创建或导入你的第一个仪表盘主题',
noCompatibleDash: '当前游戏没有兼容的仪表盘',
allCategory: '全部', builtin: '内置', custom: '自定义',
fullCompatible: '全', limitedCompatible: '限',
// Scene
sceneManagement: '场景管理', noGameSelected: '请先在侧边栏选择一个游戏',
newScene: '+ 新建场景', importTss: '导入 .tss',
noScenes: '暂无场景', createFirstScene: '点击"新建场景"创建你的第一个场景布局',
render: '渲染', edit: '编辑', delete: '删除', export: '导出',
sceneName: '场景名称', sceneDescription: '场景描述(可选)',
sceneCanvasHint: '提示:保存后可在场景编辑器中添加仪表盘、调整布局和画布比例。',
save: '保存', cancel: '取消', editScene: '编辑场景',
canvasesCount: '个画布', backToList: '← 返回', saveScene: '保存场景',
dashListDrag: '仪表盘列表 (拖到画布)', noCompatibleDashForScene: '无兼容仪表盘',
// Market
marketPage: '仪表盘市场', marketDesc: '浏览并安装社区分享的仪表盘和场景',
notLoggedIn: '未登录', loggedIn: '已登录',
login: '登录', register: '注册', logout: '退出',
publish: '发布你的作品', install: '安装', installed: '已安装',
installScene: '安装场景', uninstall: '卸载',
pending: '待审核', approved: '已通过',
marketLogin: '市场账户',
email: '邮箱', password: '密码', username: '用户名',
loginSuccess: '登录成功', loginFailed: '登录失败',
registerSuccess: '注册成功,请查收验证邮件(点击关闭)', registerFailed: '注册失败',
publishTitle: '发布作品', publishDesc: '选择要发布的仪表盘或场景',
chooseFile: '📁 选择文件 (.tsd / .tss)',
orChooseLocal: '— 或从本地选择 —',
localDashboards: '本地仪表盘', localScenes: '本地场景',
noPublishable: '没有可发布的内容', publishFailed: '发布失败',
publishSuccess: '发布成功',
uploadPreview: '上传预览图', dropPreview: '点击或拖放图片到此处 (最大 6MB)',
previewTooLarge: '预览图不能超过 6MB', imageOnly: '仅支持图片格式',
updateItem: '更新', confirmDelete: '确定删除?',
myItems: '我的', myDashboards: '我的仪表盘', myScenes: '我的场景',
noUploads: '你还没有上传任何内容',
refresh: '🔄', retry: '重试', loadFailed: '加载失败',
dashboardsTab: '仪表盘', scenesTab: '场景', myTab: '我的',
installedHint: '安装成功!请在仪表盘页面查看',
installFailed: '安装失败',
needLogin: '请先登录',
confirmUninstall: '确定卸载 "$1"',
// Record
recordPage: '录制与回放', recordDesc: '录制原始 UDP 流到 .tsr 文件,回放调试仪表盘',
recordControl: '录制控制', startRecord: '开始录制', stopRecord: '停止录制',
ready: '就绪', recording: '录制中...', autoDetect: '自动检测',
playbackActive: '正在回放: ', playbackHint: '打开仪表盘页面即可看到数据',
stopPlayback: '停止', recordFiles: '录制文件', importTsr: '导入 .tsr',
noRecordings: '暂无录制文件', play: '播放',
// Debug
debugPage: '数据测试', debugToolbar: '',
parsedFields: '解析后的数据字段', rawJsonStream: '原始 JSON 数据流',
waitingData: '等待数据...', pause: '⏸ 暂停', resume: '▶ 继续',
clear: '清空', autoScroll: '自动滚动', viewLog: '📄 日志',
// Settings
connectionSettings: '连接设置',
telemetryPort: '遥测监听端口', telemetryPortDesc: '全局统一端口',
tcpPort: 'TCP 默认端口', tcpPortDesc: 'TCP 协议默认监听端口',
unifiedPort: '统一端口', unifiedPortDesc: '关闭后切游戏自动切换到各游戏的默认端口',
unifiedPortToggle: '使用游戏独立端口',
perGamePort: '各游戏独立端口', savePorts: '保存端口',
restartListener: '重启遥测监听', restartListenerDesc: '修改端口或切换游戏后',
restartBtn: '重启监听',
dataForwarding: '数据转发', dataForwardingDesc: '将收到的游戏原始 UDP 数据包完整转发到下游物理外设',
addTarget: '+ 添加目标', saveEffective: '保存并生效',
noForwardTargets: '暂无转发目标,点击下方添加',
forwardEnabled: '启用',
dashManagement: '仪表盘管理', rightClickExport: '右键卡片导出',
pluginManagement: '游戏插件管理', importTsp: '导入 .tsp',
reloadPlugins: '重新加载', noPlugins: '暂无游戏插件',
dataBackup: '数据备份', exportBackup: '导出全量备份',
backupDesc: '打包仪表盘+场景+配置为 .tsb', downloadTsb: '下载 .tsb',
restoreBackup: '恢复备份', restoreBackupDesc: '从 .tsb 文件恢复',
importTsb: '导入 .tsb',
devTools: '开发工具', testMode: 'UI 测试模式', devTools: '开发工具', testMode: 'UI 测试模式',
system: '系统', restartServer: '重启服务', testModeDesc: '本地随机模拟遥测数据',
about: '关于', author: 'Yei.J. (AskaEth)', testModeOn: '测试模式已开启', testModeOff: '测试模式已关闭',
systemSection: '系统', restartServer: '重启 TurboSu',
restartServerDesc: '重新加载所有代码和配置', restartBtnSys: '重启服务',
confirmRestart: '确定重启 TurboSu 服务?',
restarting: '服务正在重启,3秒后自动刷新...',
about: '关于', aboutDesc: '赛车遥测仪表盘 · 极速中枢 · Yei.J. (AskaEth)',
// General
loading: '加载中...', confirm: '确定', success: '成功', error: '错误',
clickClose: '点击关闭', saved: '已保存', updated: '已更新',
deleted: '已删除', portUpdated: '端口已更新,重启后生效',
configSaved: '已保存', telemetryRestarted: '遥测监听已重启',
pluginInstalled: '插件安装成功', pluginRemoved: '插件已移除',
pluginReloaded: '插件已重新加载',
// Counters & formats
connectionsCount: '%d 个客户端', packetsCount: '%d 包',
secondsAgo: '%d秒前', gearLabel: '档位 %s',
downloadCount: '%d 次下载', downloadCountShort: '%d 次',
canvasesLabel: '%d 个画布', forwardsSaved: '已保存 %d 个转发目标',
defaultPortLabel: '默认: ',
// More UI
noGamePlugins: '暂无可用的游戏插件',
noGame: '未关联游戏', currentGamePrefix: '当前游戏: ',
community: '社区',
noScenesMarket: '暂无场景', noDashboardsMarket: '暂无仪表盘',
// Prompts / modals
dashboardNamePrompt: '仪表盘名称:',
marketLoginPrompt: '请先登录市场账户',
enterEmailPassword: '请输入邮箱和密码',
selectFileOrPreview: '请选择文件或预览图',
confirmRemove: '移除?',
// Toast: import / create / upload
importSuccess: '导入成功', importFailed: '导入失败',
createSuccess: '创建成功', createFailed: '创建失败',
uploadSuccess: '上传成功', uploadFailed: '上传失败',
uploadFailedMsg: '上传失败: ',
installSuccess: '安装成功', installFailedMsg: '安装失败: ',
remove: '移除',
uninstallSuccess: '已卸载', uninstallFailed: '卸载失败',
uninstallNotFound: '未找到本地安装',
deleteFailed: '删除失败', loggedOut: '已退出',
sceneSaveSuccess: '场景保存成功', saveFailed: '保存失败: ',
sceneImportSuccess: '场景导入成功',
playbackStarted: '回放已开始', playbackStartedHint: '回放已开始,打开仪表盘查看数据',
restoreSuccess: '恢复成功', restoreFailed: '恢复失败: ',
removeSuccess: '已移除',
exportTsdHint: '正在下载 .tsd 文件...',
linkCopied: '链接已复制,可在局域网设备访问',
linkCopiedShort: '链接已复制',
portsSaved: '端口已保存',
marketEmpty: '市场暂无仪表盘', marketLoadFailed: '市场加载失败',
installDataIncomplete: '安装失败:数据不完整',
installNetworkError: '安装失败:网络错误',
restartRequestSent: '重启请求已发送,请手动刷新',
marketNoItems: '暂无',
confirmUpdate: '确认更新',
}, },
en: { en: {
home: 'Home', dashboard: 'Dashboard', scene: 'Scene', settings: 'Settings', home: 'Home', dashboard: 'Dashboard', scene: 'Scene', market: 'Market',
debug: 'Debug', selectGame: 'Select Game', collapsed: 'Collapse', record: 'Record', debug: 'Debug', settings: 'Settings',
connected: 'Connected', disconnected: 'Disconnected', speed: 'Speed', rpm: 'RPM', selectGame: 'Select Game', collapseSidebar: 'Collapse',
gear: 'Gear', server: 'Server', telemetry: 'Telemetry',
connections: 'Connections', packets: 'Packets', lastPacket: 'Last Packet', connected: 'Connected', disconnected: 'Disconnected', connectionError: 'Error',
currentGame: 'Game', systemOverview: 'Overview', running: 'Running',
stopped: 'Stopped', notStarted: 'Off', noData: 'No Data', client: ' clients', serverStatus: 'Server', telemetryStatus: 'Telemetry',
notSelected: 'None', packetsUnit: 'pkt', secAgo: 's ago', wsConnections: 'Connections', packetCount: 'Packets',
importTsd: 'Import .tsd', importTss: 'Import .tss', importTsp: 'Import .tsp', lastPacket: 'Last Packet', currentGame: 'Game',
export: 'Export', delete: 'Delete', edit: 'Edit', render: 'Render', save: 'Save', liveSpeed: 'Speed', liveRpm: 'RPM', liveGear: 'Gear',
cancel: 'Cancel', create: 'New Scene', sceneName: 'Name', systemOverview: 'System Overview', overviewDesc: 'Real-time monitoring',
search: 'Search...', sort: 'Sort', all: 'All', none: 'None', running: 'Running', stopped: 'Stopped', notStarted: 'Off',
forward: 'Forward', addTarget: '+ Add', saveActive: 'Save', noData: 'No Data', notSelected: 'None',
connectionSettings: 'Connection', telemetryPort: 'Port', listening: 'Listening',
unifiedPort: 'Unified', perGamePort: 'Per Game',
restart: 'Restart', pluginSettings: 'Plugins', searchDashboards: 'Search...', sortByName: 'Name', sortByCategory: 'Category',
plugins: 'Plugins', reload: 'Reload', backup: 'Backup', sortByAuthor: 'Author', importTsd: 'Import .tsd', newDashboard: '+ New',
exportBackup: 'Download .tsb', importBackup: 'Import .tsb', noDashboards: 'No dashboards', createFirstDash: 'Create or import your first dashboard',
devTools: 'Dev Tools', testMode: 'Test Mode', noCompatibleDash: 'No compatible dashboards for current game',
system: 'System', restartServer: 'Restart', allCategory: 'All', builtin: 'Built-in', custom: 'Custom',
about: 'About', author: 'Yei.J. (AskaEth)', fullCompatible: 'All', limitedCompatible: 'Lim',
sceneManagement: 'Scene Manager', noGameSelected: 'Select a game first',
newScene: '+ New Scene', importTss: 'Import .tss',
noScenes: 'No scenes', createFirstScene: 'Create your first scene',
render: 'Render', edit: 'Edit', delete: 'Delete', export: 'Export',
sceneName: 'Name', sceneDescription: 'Description (optional)',
sceneCanvasHint: 'Tip: Save first, then edit layout in scene editor.',
save: 'Save', cancel: 'Cancel', editScene: 'Edit Scene',
canvasesCount: 'canvases', backToList: '← Back', saveScene: 'Save Scene',
dashListDrag: 'Dashboards (drag to canvas)', noCompatibleDashForScene: 'No compatible dashboards',
marketPage: 'Marketplace', marketDesc: 'Browse and install community dashboards & scenes',
notLoggedIn: 'Not logged in', loggedIn: 'Logged in',
login: 'Login', register: 'Register', logout: 'Logout',
publish: 'Publish', install: 'Install', installed: 'Installed',
installScene: 'Install Scene', uninstall: 'Uninstall',
pending: 'Pending', approved: 'Approved',
marketLogin: 'Account',
email: 'Email', password: 'Password', username: 'Username',
loginSuccess: 'Login successful', loginFailed: 'Login failed',
registerSuccess: 'Registered! Check email to verify (click to close)', registerFailed: 'Registration failed',
publishTitle: 'Publish', publishDesc: 'Select a dashboard or scene',
chooseFile: '📁 Choose file (.tsd / .tss)',
orChooseLocal: '— or select from local —',
localDashboards: 'Local Dashboards', localScenes: 'Local Scenes',
noPublishable: 'Nothing to publish', publishFailed: 'Publish failed',
publishSuccess: 'Published successfully',
uploadPreview: 'Upload Preview', dropPreview: 'Click or drop image (max 6MB)',
previewTooLarge: 'Preview too large (6MB max)', imageOnly: 'Images only',
updateItem: 'Update', confirmDelete: 'Confirm delete?',
myItems: 'Mine', myDashboards: 'My Dashboards', myScenes: 'My Scenes',
noUploads: 'Nothing uploaded yet',
refresh: '🔄', retry: 'Retry', loadFailed: 'Load failed',
dashboardsTab: 'Dashboards', scenesTab: 'Scenes', myTab: 'Mine',
installedHint: 'Installed! Check dashboard page',
installFailed: 'Install failed',
needLogin: 'Please login first',
confirmUninstall: 'Uninstall "$1"?',
recordPage: 'Record & Playback', recordDesc: 'Record raw UDP to .tsr, replay for debugging',
recordControl: 'Record Control', startRecord: 'Start', stopRecord: 'Stop',
ready: 'Ready', recording: 'Recording...', autoDetect: 'Auto Detect',
playbackActive: 'Playing: ', playbackHint: 'Open a dashboard to see data',
stopPlayback: 'Stop', recordFiles: 'Recordings', importTsr: 'Import .tsr',
noRecordings: 'No recordings', play: 'Play',
debugPage: 'Debug', debugToolbar: '',
parsedFields: 'Parsed Fields', rawJsonStream: 'Raw JSON Stream',
waitingData: 'Waiting...', pause: '⏸ Pause', resume: '▶ Resume',
clear: 'Clear', autoScroll: 'Auto Scroll', viewLog: '📄 Log',
connectionSettings: 'Connection',
telemetryPort: 'Telemetry Port', telemetryPortDesc: 'Global port',
tcpPort: 'TCP Port', tcpPortDesc: 'Default TCP port',
unifiedPort: 'Unified Port', unifiedPortDesc: 'Turn off for per-game ports',
unifiedPortToggle: 'Use per-game ports',
perGamePort: 'Per-Game Ports', savePorts: 'Save Ports',
restartListener: 'Restart Listener', restartListenerDesc: 'After port/game change',
restartBtn: 'Restart',
dataForwarding: 'Data Forwarding', dataForwardingDesc: 'Forward raw UDP to downstream devices',
addTarget: '+ Add', saveEffective: 'Save',
noForwardTargets: 'No targets, add below',
forwardEnabled: 'Enabled',
dashManagement: 'Dashboard Management', rightClickExport: 'Right-click to export',
pluginManagement: 'Plugin Management', importTsp: 'Import .tsp',
reloadPlugins: 'Reload', noPlugins: 'No plugins',
dataBackup: 'Backup', exportBackup: 'Export Backup',
backupDesc: 'Pack dashboards+scenes+config as .tsb', downloadTsb: 'Download .tsb',
restoreBackup: 'Restore Backup', restoreBackupDesc: 'Restore from .tsb',
importTsb: 'Import .tsb',
devTools: 'Dev Tools', testMode: 'UI Test Mode',
testModeDesc: 'Simulate telemetry locally',
testModeOn: 'Test mode on', testModeOff: 'Test mode off',
systemSection: 'System', restartServer: 'Restart TurboSu',
restartServerDesc: 'Reload all code and config', restartBtnSys: 'Restart',
confirmRestart: 'Restart TurboSu?',
restarting: 'Restarting, will refresh in 3s...',
about: 'About', aboutDesc: 'Racing Telemetry Dashboard · Yei.J. (AskaEth)',
loading: 'Loading...', confirm: 'Confirm', success: 'Success', error: 'Error',
clickClose: 'Click to close', saved: 'Saved', updated: 'Updated',
deleted: 'Deleted', portUpdated: 'Port updated, restart to apply',
configSaved: 'Saved', telemetryRestarted: 'Listener restarted',
pluginInstalled: 'Plugin installed', pluginRemoved: 'Plugin removed',
pluginReloaded: 'Plugins reloaded',
connectionsCount: '%d clients', packetsCount: '%d packets',
secondsAgo: '%ds ago', gearLabel: 'Gear %s',
downloadCount: '%d downloads', downloadCountShort: '%d',
canvasesLabel: '%d canvases', forwardsSaved: '%d targets saved',
defaultPortLabel: 'Default: ',
noGamePlugins: 'No game plugins available',
noGame: 'No game', currentGamePrefix: 'Current game: ',
community: 'Community',
noScenesMarket: 'No scenes', noDashboardsMarket: 'No dashboards',
dashboardNamePrompt: 'Dashboard name:',
marketLoginPrompt: 'Please login first',
enterEmailPassword: 'Enter email and password',
selectFileOrPreview: 'Select file or preview',
confirmRemove: 'Remove?',
importSuccess: 'Imported', importFailed: 'Import failed',
createSuccess: 'Created', createFailed: 'Create failed',
uploadSuccess: 'Uploaded', uploadFailed: 'Upload failed',
uploadFailedMsg: 'Upload failed: ',
installSuccess: 'Installed', installFailedMsg: 'Install failed: ',
remove: 'Remove',
uninstallSuccess: 'Uninstalled', uninstallFailed: 'Uninstall failed',
uninstallNotFound: 'Not found locally',
deleteFailed: 'Delete failed', loggedOut: 'Logged out',
sceneSaveSuccess: 'Scene saved', saveFailed: 'Save failed: ',
sceneImportSuccess: 'Scene imported',
playbackStarted: 'Playback started', playbackStartedHint: 'Playback started, open dashboard to view',
restoreSuccess: 'Restored', restoreFailed: 'Restore failed: ',
removeSuccess: 'Removed',
exportTsdHint: 'Downloading .tsd...',
linkCopied: 'Link copied, accessible on LAN',
linkCopiedShort: 'Link copied',
portsSaved: 'Ports saved',
marketEmpty: 'No dashboards on market', marketLoadFailed: 'Market load failed',
installDataIncomplete: 'Incomplete data',
installNetworkError: 'Network error',
restartRequestSent: 'Restart sent, refresh manually',
marketNoItems: 'None',
confirmUpdate: 'Confirm Update',
} }
}, },
@@ -53,12 +335,25 @@ const Lang = {
this._current = localStorage.getItem('turbosu-lang') || 'zh'; this._current = localStorage.getItem('turbosu-lang') || 'zh';
const btn = document.getElementById('lang-toggle'); const btn = document.getElementById('lang-toggle');
if (btn) btn.textContent = this._current === 'zh' ? 'EN' : '中'; if (btn) btn.textContent = this._current === 'zh' ? 'EN' : '中';
this._scan();
}, },
t(key) { t(key) {
return (this._strings[this._current] || this._strings.zh)[key] || key; return (this._strings[this._current] || this._strings.zh)[key] || key;
}, },
_scan() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const text = this.t(key);
if (el.tagName === 'INPUT' && (el.type === 'text' || el.type === 'search' || el.type === 'email' || el.type === 'password')) {
el.placeholder = text;
} else {
el.textContent = text;
}
});
},
toggle() { toggle() {
this._current = this._current === 'zh' ? 'en' : 'zh'; this._current = this._current === 'zh' ? 'en' : 'zh';
localStorage.setItem('turbosu-lang', this._current); localStorage.setItem('turbosu-lang', this._current);