From 47341f1f763fe6456ee5438a8809e1cf4a8e92e3 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 26 Jul 2026 21:37:24 +0800 Subject: [PATCH] feat: market scenes tab - upload/install with dependency check --- server/api.py | 88 ++++++++++++++++++++++++++++++++++++++- static/js/pages/market.js | 62 +++++++++++++++++++++------ 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/server/api.py b/server/api.py index 5303329..ff7e987 100644 --- a/server/api.py +++ b/server/api.py @@ -557,8 +557,13 @@ async def api_market_upload(file: UploadFile = File(...), name: str = "", catego return {"error": "无效的仪表盘文件:缺少 manifest.json"} if 'index.html' not in names: return {"error": "无效的仪表盘文件:缺少 index.html"} - manifest = _json.loads(zf.read('manifest.json').decode('utf-8')) + + if not manifest.get('id'): + return {"error": "manifest.json 缺少 id"} + if not manifest.get('name'): + return {"error": "manifest.json 缺少 name"} + actual_name = name or manifest.get('name', file.filename.replace('.tsd', '')) actual_category = category or manifest.get('category', 'community') actual_author = author or manifest.get('author', 'Unknown') @@ -604,3 +609,84 @@ async def api_market_install(id: str = "", filename: str = ""): return {"ok": ok} except Exception as e: return {"ok": False, "error": str(e)} + + +# ---- Market Scenes ---- +@router.get("/market/scenes") +async def api_market_scenes(): + 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/scenes/records?sort=-created&perPage=50") + return resp.json() + except Exception: + return {"items": []} + + +@router.post("/market/scenes/upload") +async def api_market_upload_scene(file: UploadFile = File(...), token: str = ""): + import io, zipfile, json as _json + if not file.filename or not file.filename.endswith('.tss'): + return {"error": "仅支持 .tss 文件"} + try: + zip_data = await file.read() + with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf: + if 'scene.json' not in zf.namelist(): + return {"error": "缺少 scene.json"} + scene = _json.loads(zf.read('scene.json').decode('utf-8')) + if not scene.get('name'): + return {"error": "scene.json 缺少 name"} + deps = set() + for canvas in scene.get('canvases', []): + for p in canvas.get('placements', []): + if p.get('dashboard_id'): + deps.add(p['dashboard_id']) + except zipfile.BadZipFile: + return {"error": "无效的 zip 文件"} + except Exception as e: + return {"error": str(e)} + + import httpx as _httpx + cfg = get_config() + url = cfg.get("market_url", "http://127.0.0.1:5301") + try: + async with _httpx.AsyncClient(timeout=30) as client: + resp = await client.post( + f"{url}/api/collections/scenes/records", + headers={"Authorization": f"Bearer {token}"}, + data={"name": scene.get('name'), "description": scene.get('description',''), "game_id": scene.get('game_id',''), "author": scene.get('author','') or "Unknown", "dashb_pending": True}, + files={"file": (file.filename, zip_data, file.content_type)}, + ) + result = resp.json() + result["dependencies"] = list(deps) + return result + except Exception as e: + return {"error": str(e)} + + +@router.get("/market/scenes/install") +async def api_market_install_scene(id: str = "", filename: str = ""): + import httpx, io, zipfile, json as _json + 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/scenes/{id}/{filename}") + zip_data = resp.content + missing = [] + with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf: + if 'scene.json' in zf.namelist(): + scene = _json.loads(zf.read('scene.json').decode('utf-8')) + from models.dashboard import dashboard_manager + for canvas in scene.get('canvases', []): + for p in canvas.get('placements', []): + did = p.get('dashboard_id', '') + if did and not dashboard_manager.get(did): + missing.append(did) + from models.scene import scene_manager + ok = scene_manager.import_scene_zip(zip_data) + return {"ok": ok, "missing_dashboards": missing} + except Exception as e: + return {"ok": False, "error": str(e)} diff --git a/static/js/pages/market.js b/static/js/pages/market.js index 7be221a..4eb85b2 100644 --- a/static/js/pages/market.js +++ b/static/js/pages/market.js @@ -1,6 +1,7 @@ const PageMarket = { _el: null, _marketToken: null, + _tab: 'dashboards', init() { this._el = document.getElementById('page-market'); @@ -18,24 +19,29 @@ const PageMarket = { const grid = document.getElementById('market-grid'); if (!grid) return; grid.innerHTML = '
加载中...
'; + + const isScene = this._tab === 'scenes'; + const endpoint = isScene ? '/api/market/scenes' : '/api/market/dashboards'; try { - const res = await fetch('/api/market/dashboards'); + const res = await fetch(endpoint); const data = await res.json(); const items = data.items || []; - if (!items.length) { grid.innerHTML = this._emptyHtml('市场暂无仪表盘'); return; } + if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? '暂无场景' : '暂无仪表盘'); return; } grid.innerHTML = items.map(d => { - const icon = d.icon || '📦'; + const icon = isScene ? '🎬' : (d.icon || '📦'); + const btnLabel = isScene ? '安装场景' : '安装'; + const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install'; return `
${icon}
${d.name||'Unnamed'}
- ${d.category||''}v${d.version||'1.0'} + ${isScene ? (d.game_id||'') : (d.category||'')}${isScene ? '' : 'v'+(d.version||'1.0')}
${d.author||''}${d.downloads||0} 次
- +
`; }).join(''); } catch (e) { grid.innerHTML = '
加载失败
'; } @@ -45,14 +51,39 @@ const PageMarket = { document.getElementById('btn-m-login')?.addEventListener('click', () => this._showLogin()); document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload()); document.getElementById('market-grid')?.addEventListener('click', async (e) => { - const btn = e.target.closest('.btn-m-install'); - if (!btn) return; + const btnInstall = e.target.closest('.btn-m-install'); + const btnScene = e.target.closest('.btn-m-install-scene'); + const id = (btnInstall || btnScene)?.dataset.id; + const file = (btnInstall || btnScene)?.dataset.file; + if (!id || !file) return; + const isScene = !!btnScene; + const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}` + : `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`; try { - const res = await fetch(`/api/market/install?id=${encodeURIComponent(btn.dataset.id)}&filename=${encodeURIComponent(btn.dataset.file)}`); + const res = await fetch(url); const data = await res.json(); - Toast.show(data.ok ? '安装成功' : '安装失败: ' + (data.error||''), data.ok ? 'success' : 'error'); + if (data.ok) { + let msg = '安装成功'; + if (data.missing_dashboards && data.missing_dashboards.length > 0) + msg += ',缺少 ' + data.missing_dashboards.length + ' 个依赖仪表盘'; + Toast.show(msg, 'success'); + } else { + Toast.show('安装失败: ' + (data.error||''), 'error'); + } } catch (e) { Toast.show('安装失败', 'error'); } }); + document.getElementById('tab-dashboards')?.addEventListener('click', () => { + this._tab = 'dashboards'; + document.getElementById('tab-dashboards').classList.add('active'); + document.getElementById('tab-scenes').classList.remove('active'); + this._loadItems(); + }); + document.getElementById('tab-scenes')?.addEventListener('click', () => { + this._tab = 'scenes'; + document.getElementById('tab-scenes').classList.add('active'); + document.getElementById('tab-dashboards').classList.remove('active'); + this._loadItems(); + }); }, _updateUI() { @@ -74,7 +105,8 @@ const PageMarket = { }, _upload() { - const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd'; + const input = document.createElement('input'); input.type = 'file'; input.accept = this._tab === 'scenes' ? '.tss' : '.tsd'; + const endpoint = this._tab === 'scenes' ? '/api/market/scenes/upload' : '/api/market/upload'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const fd = new FormData(); fd.append('file', file); @@ -152,16 +184,20 @@ const PageMarket = { _template() { return `
-
+

仪表盘市场

-

浏览并安装社区分享的仪表盘 未登录

+

浏览并安装社区分享的仪表盘和场景 未登录

- +
+
+ + +
`; }