feat: marketplace integration via backend proxy
- market_url config (server-side only, never exposed to frontend) - /api/market/* proxy endpoints to PocketBase - Dashboard page: 'Market' tab in sub-sidebar - Market list: browse/install community dashboards - Login/register modal, upload to market - PocketBase API fully proxied, zero credentials in frontend
This commit is contained in:
@@ -20,6 +20,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"use_unified_port": True,
|
"use_unified_port": True,
|
||||||
"game_ports": {},
|
"game_ports": {},
|
||||||
"ui_test_mode": False,
|
"ui_test_mode": False,
|
||||||
|
"market_url": "http://127.0.0.1:5301",
|
||||||
"telemetry_host": "0.0.0.0",
|
"telemetry_host": "0.0.0.0",
|
||||||
"server_host": "0.0.0.0",
|
"server_host": "0.0.0.0",
|
||||||
"server_port": 9527,
|
"server_port": 9527,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ websockets==14.1
|
|||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.18
|
||||||
|
httpx==0.28.1
|
||||||
|
|||||||
@@ -487,3 +487,88 @@ async def api_delete_recording(filename: str):
|
|||||||
path = RECORDINGS_DIR / filename
|
path = RECORDINGS_DIR / filename
|
||||||
if path.exists(): path.unlink()
|
if path.exists(): path.unlink()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Market Proxy ----
|
||||||
|
@router.get("/market/dashboards")
|
||||||
|
async def api_market_list():
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "http://127.0.0.1:5301")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(f"{url}/api/collections/dashboards/records?sort=-created&perPage=50")
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
return {"items": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/auth")
|
||||||
|
async def api_market_auth(data: dict):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "http://127.0.0.1:5301")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/users/auth-with-password",
|
||||||
|
json={"identity": data.get("email"), "password": data.get("password")}
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/register")
|
||||||
|
async def api_market_register(data: dict):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "http://127.0.0.1:5301")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/users/records",
|
||||||
|
json={
|
||||||
|
"email": data.get("email"),
|
||||||
|
"password": data.get("password"),
|
||||||
|
"passwordConfirm": data.get("passwordConfirm"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market/upload")
|
||||||
|
async def api_market_upload(file: UploadFile = File(...), name: str = "", category: str = "", author: str = "", token: str = ""):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "http://127.0.0.1:5301")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
files_httpx = {"file": (file.filename, await file.read(), file.content_type)}
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/collections/dashboards/records",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
data={"name": name, "category": category, "author": author, "version": "1.0.0"},
|
||||||
|
files=files_httpx,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/market/install")
|
||||||
|
async def api_market_install(id: str = "", filename: str = ""):
|
||||||
|
import httpx
|
||||||
|
cfg = get_config()
|
||||||
|
url = cfg.get("market_url", "http://127.0.0.1:5301")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(f"{url}/api/files/dashboards/{id}/{filename}")
|
||||||
|
zip_data = resp.content
|
||||||
|
from models.dashboard import dashboard_manager
|
||||||
|
ok = dashboard_manager.import_theme_zip(zip_data)
|
||||||
|
return {"ok": ok}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|||||||
+130
-45
@@ -2,9 +2,12 @@ const PageDashboard = {
|
|||||||
_el: null,
|
_el: null,
|
||||||
_currentCategory: 'all',
|
_currentCategory: 'all',
|
||||||
_selectedGameId: null,
|
_selectedGameId: null,
|
||||||
|
_marketMode: false,
|
||||||
|
_marketToken: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._el = document.getElementById('page-dashboard');
|
this._el = document.getElementById('page-dashboard');
|
||||||
|
this._marketToken = localStorage.getItem('turbosu-market-token');
|
||||||
},
|
},
|
||||||
|
|
||||||
async render() {
|
async render() {
|
||||||
@@ -35,6 +38,12 @@ const PageDashboard = {
|
|||||||
item.dataset.cat = cat;
|
item.dataset.cat = cat;
|
||||||
listEl.appendChild(item);
|
listEl.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const marketItem = document.createElement('div');
|
||||||
|
marketItem.className = 'category-item market-tab';
|
||||||
|
marketItem.textContent = '市场';
|
||||||
|
marketItem.dataset.cat = 'market';
|
||||||
|
listEl.appendChild(marketItem);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load categories:', e);
|
console.error('Failed to load categories:', e);
|
||||||
}
|
}
|
||||||
@@ -43,38 +52,48 @@ 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);">加载中...</div>';
|
||||||
|
|
||||||
|
if (this._marketMode) {
|
||||||
|
await this._loadMarket(gridEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let themes = await API.getDashboards(this._currentCategory);
|
let themes = await API.getDashboards(this._currentCategory);
|
||||||
if (!themes || themes.length === 0) {
|
if (!themes || themes.length === 0) { gridEl.innerHTML = this._emptyHtml(); return; }
|
||||||
gridEl.innerHTML = this._emptyHtml();
|
if (this._selectedGameId) { themes = themes.filter(t => this._isGameSupported(t)); }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._selectedGameId) {
|
|
||||||
themes = themes.filter(t => this._isGameSupported(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || '';
|
const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || '';
|
||||||
if (searchQuery) {
|
if (searchQuery) { themes = themes.filter(t => (t.name||'').toLowerCase().includes(searchQuery) || (t.description||'').toLowerCase().includes(searchQuery) || (t.author||'').toLowerCase().includes(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('当前游戏没有兼容的仪表盘');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
gridEl.innerHTML = themes.map(t => this._themeCardHtml(t)).join('');
|
||||||
} catch (e) {
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; }
|
||||||
console.error('Failed to load themes:', e);
|
},
|
||||||
gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>';
|
|
||||||
}
|
async _loadMarket(gridEl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/market/dashboards');
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
if (!items.length) { gridEl.innerHTML = this._emptyHtml('市场暂无仪表盘'); return; }
|
||||||
|
gridEl.innerHTML = items.map(d => {
|
||||||
|
const icon = d.icon || '📦';
|
||||||
|
return `<div class="glass-card theme-card" data-market-id="${d.id}">
|
||||||
|
<div class="theme-card-preview">${icon}</div>
|
||||||
|
<div class="theme-card-info">
|
||||||
|
<div class="theme-card-name">${d.name||'Unnamed'}</div>
|
||||||
|
<div class="theme-card-meta">
|
||||||
|
<span>${d.category||''}</span><span>v${d.version||'1.0'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card-meta" style="margin-top:4px">
|
||||||
|
<span>${d.author||''}</span><span style="color:var(--text-tertiary)">${d.downloads||0} 次下载</span>
|
||||||
|
</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>
|
||||||
|
</div></div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) { gridEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">市场加载失败</div>'; }
|
||||||
},
|
},
|
||||||
|
|
||||||
_isGameSupported(theme) {
|
_isGameSupported(theme) {
|
||||||
@@ -102,6 +121,8 @@ const PageDashboard = {
|
|||||||
categoryList.querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
|
categoryList.querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
|
||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
this._currentCategory = item.dataset.cat;
|
this._currentCategory = item.dataset.cat;
|
||||||
|
this._marketMode = (this._currentCategory === 'market');
|
||||||
|
document.getElementById('dash-search-box').style.display = this._marketMode ? 'none' : 'flex';
|
||||||
this._loadThemes();
|
this._loadThemes();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,14 +133,16 @@ const PageDashboard = {
|
|||||||
const card = e.target.closest('.theme-card');
|
const card = e.target.closest('.theme-card');
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const themeId = card.dataset.themeId;
|
const themeId = card.dataset.themeId;
|
||||||
this._openDashboard(themeId);
|
if (themeId) this._openDashboard(themeId);
|
||||||
|
|
||||||
|
const installBtn = e.target.closest('.btn-market-install');
|
||||||
|
if (installBtn) this._installFromMarket(installBtn.dataset.id, installBtn.dataset.file);
|
||||||
});
|
});
|
||||||
gridEl.addEventListener('contextmenu', (e) => {
|
gridEl.addEventListener('contextmenu', (e) => {
|
||||||
const card = e.target.closest('.theme-card');
|
const card = e.target.closest('.theme-card');
|
||||||
if (!card) return;
|
if (!card || !card.dataset.themeId) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const themeId = card.dataset.themeId;
|
this._exportDashboard(card.dataset.themeId);
|
||||||
this._exportDashboard(themeId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,22 +150,83 @@ const PageDashboard = {
|
|||||||
document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes());
|
document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes());
|
||||||
|
|
||||||
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
|
||||||
input.type = 'file';
|
|
||||||
input.accept = '.tsd';
|
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0]; if (!file) return;
|
||||||
if (!file) return;
|
const fd = new FormData(); fd.append('file', file);
|
||||||
const formData = new FormData();
|
const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
|
||||||
formData.append('file', file);
|
const data = await res.json();
|
||||||
try {
|
if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); }
|
||||||
const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
|
else Toast.show('导入失败', 'error');
|
||||||
const data = await res.json();
|
}; input.click();
|
||||||
if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
|
});
|
||||||
else Toast.show('导入失败', 'error');
|
|
||||||
} catch (err) { Toast.show('导入失败', 'error'); }
|
document.getElementById('btn-market-upload')?.addEventListener('click', () => this._showMarketUpload());
|
||||||
};
|
},
|
||||||
input.click();
|
|
||||||
|
async _installFromMarket(id, filename) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/market/install?id=${id}&filename=${filename}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) { Toast.show('安装成功', 'success'); this._loadThemes(); }
|
||||||
|
else Toast.show('安装失败', 'error');
|
||||||
|
} catch (e) { Toast.show('安装失败', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
_showMarketUpload() {
|
||||||
|
if (!this._marketToken) {
|
||||||
|
Toast.show('请先登录市场账户', 'info');
|
||||||
|
this._showMarketLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0]; if (!file) return;
|
||||||
|
const fd = new FormData(); fd.append('file', file);
|
||||||
|
fd.append('name', file.name.replace('.tsd',''));
|
||||||
|
fd.append('category', 'community');
|
||||||
|
fd.append('author', 'TurboSu User');
|
||||||
|
fd.append('token', this._marketToken);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/market/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
Toast.show(data.error ? '上传失败: ' + data.error : '上传成功', data.error ? 'error' : 'success');
|
||||||
|
} catch (e) { Toast.show('上传失败', 'error'); }
|
||||||
|
}; input.click();
|
||||||
|
},
|
||||||
|
|
||||||
|
_showMarketLogin() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `<div class="modal" style="min-width:360px;max-width:400px">
|
||||||
|
<h3>市场登录</h3>
|
||||||
|
<div class="form-group"><label>邮箱</label><input id="ml-email" type="email"></div>
|
||||||
|
<div class="form-group"><label>密码</label><input id="ml-pass" type="password"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="ml-register">注册</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="ml-login">登录</button>
|
||||||
|
</div></div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
overlay.querySelector('#ml-login').addEventListener('click', async () => {
|
||||||
|
const email = document.getElementById('ml-email').value;
|
||||||
|
const pass = document.getElementById('ml-pass').value;
|
||||||
|
const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password}) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.token) {
|
||||||
|
this._marketToken = data.token;
|
||||||
|
localStorage.setItem('turbosu-market-token', data.token);
|
||||||
|
Toast.show('登录成功', 'success');
|
||||||
|
overlay.remove();
|
||||||
|
} else { Toast.show(data.message || '登录失败', 'error'); }
|
||||||
|
});
|
||||||
|
overlay.querySelector('#ml-register').addEventListener('click', async () => {
|
||||||
|
const email = document.getElementById('ml-email').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 data = await res.json();
|
||||||
|
Toast.show(data.error ? '注册失败: ' + data.error : '注册成功,请查收验证邮件', data.error ? 'error' : 'success');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -194,12 +278,13 @@ const PageDashboard = {
|
|||||||
<div class="dashboard-layout">
|
<div class="dashboard-layout">
|
||||||
<div class="dashboard-sub-sidebar">
|
<div class="dashboard-sub-sidebar">
|
||||||
<div id="dash-category-list"></div>
|
<div id="dash-category-list"></div>
|
||||||
<div style="padding:8px 12px;margin-top:auto;">
|
<div style="padding:8px 12px;margin-top:auto;display:flex;flex-direction:column;gap:4px">
|
||||||
|
<button id="btn-market-upload" class="btn btn-sm btn-secondary" style="width:100%;display:none">发布到市场</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%;">导入 .tsd</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-main">
|
<div class="dashboard-main">
|
||||||
<div 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="搜索仪表盘..." 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">名称</option>
|
||||||
|
|||||||
Reference in New Issue
Block a user