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 = {
-
+
+
-
+