diff --git a/config/settings.py b/config/settings.py
index 33de64f..ad558d6 100644
--- a/config/settings.py
+++ b/config/settings.py
@@ -20,6 +20,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"use_unified_port": True,
"game_ports": {},
"ui_test_mode": False,
+ "market_url": "http://127.0.0.1:5301",
"telemetry_host": "0.0.0.0",
"server_host": "0.0.0.0",
"server_port": 9527,
diff --git a/requirements.txt b/requirements.txt
index 9d17d20..029b3e9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,4 @@ websockets==14.1
aiofiles==24.1.0
jinja2==3.1.4
python-multipart==0.0.18
+httpx==0.28.1
diff --git a/server/api.py b/server/api.py
index a334ac3..ad9c902 100644
--- a/server/api.py
+++ b/server/api.py
@@ -487,3 +487,88 @@ async def api_delete_recording(filename: str):
path = RECORDINGS_DIR / filename
if path.exists(): path.unlink()
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)}
diff --git a/static/js/pages/dashboard.js b/static/js/pages/dashboard.js
index b9421c3..10341a0 100644
--- a/static/js/pages/dashboard.js
+++ b/static/js/pages/dashboard.js
@@ -2,9 +2,12 @@ const PageDashboard = {
_el: null,
_currentCategory: 'all',
_selectedGameId: null,
+ _marketMode: false,
+ _marketToken: null,
init() {
this._el = document.getElementById('page-dashboard');
+ this._marketToken = localStorage.getItem('turbosu-market-token');
},
async render() {
@@ -35,6 +38,12 @@ const PageDashboard = {
item.dataset.cat = cat;
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) {
console.error('Failed to load categories:', e);
}
@@ -43,38 +52,48 @@ const PageDashboard = {
async _loadThemes() {
const gridEl = document.getElementById('dash-theme-grid');
if (!gridEl) return;
-
gridEl.innerHTML = '
加载中...
';
+ if (this._marketMode) {
+ await this._loadMarket(gridEl);
+ return;
+ }
+
try {
let themes = await API.getDashboards(this._currentCategory);
- if (!themes || themes.length === 0) {
- gridEl.innerHTML = this._emptyHtml();
- return;
- }
-
- if (this._selectedGameId) {
- themes = themes.filter(t => this._isGameSupported(t));
- }
-
+ if (!themes || themes.length === 0) { gridEl.innerHTML = this._emptyHtml(); return; }
+ if (this._selectedGameId) { themes = themes.filter(t => this._isGameSupported(t)); }
const searchQuery = document.getElementById('dash-search')?.value?.toLowerCase() || '';
- 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';
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('');
- } catch (e) {
- console.error('Failed to load themes:', e);
- gridEl.innerHTML = '加载失败
';
- }
+ } catch (e) { gridEl.innerHTML = '加载失败
'; }
+ },
+
+ 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 `
+
${icon}
+
+
${d.name||'Unnamed'}
+
+ ${d.category||''}v${d.version||'1.0'}
+
+
+ ${d.author||''}${d.downloads||0} 次下载
+
+
+
`;
+ }).join('');
+ } catch (e) { gridEl.innerHTML = '市场加载失败
'; }
},
_isGameSupported(theme) {
@@ -102,6 +121,8 @@ const PageDashboard = {
categoryList.querySelectorAll('.category-item').forEach(el => el.classList.remove('active'));
item.classList.add('active');
this._currentCategory = item.dataset.cat;
+ this._marketMode = (this._currentCategory === 'market');
+ document.getElementById('dash-search-box').style.display = this._marketMode ? 'none' : 'flex';
this._loadThemes();
});
}
@@ -112,14 +133,16 @@ const PageDashboard = {
const card = e.target.closest('.theme-card');
if (!card) return;
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) => {
const card = e.target.closest('.theme-card');
- if (!card) return;
+ if (!card || !card.dataset.themeId) return;
e.preventDefault();
- const themeId = card.dataset.themeId;
- this._exportDashboard(themeId);
+ this._exportDashboard(card.dataset.themeId);
});
}
@@ -127,22 +150,83 @@ const PageDashboard = {
document.getElementById('dash-sort')?.addEventListener('change', () => this._loadThemes());
document.getElementById('btn-import-dashboard')?.addEventListener('click', () => {
- const input = document.createElement('input');
- input.type = 'file';
- input.accept = '.tsd';
+ const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd';
input.onchange = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- const formData = new FormData();
- formData.append('file', file);
- try {
- const res = await fetch('/api/dashboards/import', { method: 'POST', body: formData });
- const data = await res.json();
- if (data.ok) { Toast.show('仪表盘导入成功', 'success'); this._loadThemes(); }
- else Toast.show('导入失败', 'error');
- } catch (err) { Toast.show('导入失败', 'error'); }
- };
- input.click();
+ const file = e.target.files[0]; if (!file) return;
+ const fd = new FormData(); fd.append('file', file);
+ const res = await fetch('/api/dashboards/import', { method: 'POST', body: fd });
+ const data = await res.json();
+ if (data.ok) { Toast.show('导入成功', 'success'); this._loadThemes(); }
+ else Toast.show('导入失败', 'error');
+ }; input.click();
+ });
+
+ document.getElementById('btn-market-upload')?.addEventListener('click', () => this._showMarketUpload());
+ },
+
+ 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 = `
+
市场登录
+
+
+
+
+
+
`;
+ 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 = {