feat: market scenes tab - upload/install with dependency check

This commit is contained in:
2026-07-26 21:37:24 +08:00
parent e6344b1916
commit 47341f1f76
2 changed files with 136 additions and 14 deletions
+87 -1
View File
@@ -557,8 +557,13 @@ async def api_market_upload(file: UploadFile = File(...), name: str = "", catego
return {"error": "无效的仪表盘文件:缺少 manifest.json"} return {"error": "无效的仪表盘文件:缺少 manifest.json"}
if 'index.html' not in names: if 'index.html' not in names:
return {"error": "无效的仪表盘文件:缺少 index.html"} return {"error": "无效的仪表盘文件:缺少 index.html"}
manifest = _json.loads(zf.read('manifest.json').decode('utf-8')) 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_name = name or manifest.get('name', file.filename.replace('.tsd', ''))
actual_category = category or manifest.get('category', 'community') actual_category = category or manifest.get('category', 'community')
actual_author = author or manifest.get('author', 'Unknown') actual_author = author or manifest.get('author', 'Unknown')
@@ -604,3 +609,84 @@ async def api_market_install(id: str = "", filename: str = ""):
return {"ok": ok} return {"ok": ok}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(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)}
+49 -13
View File
@@ -1,6 +1,7 @@
const PageMarket = { const PageMarket = {
_el: null, _el: null,
_marketToken: null, _marketToken: null,
_tab: 'dashboards',
init() { init() {
this._el = document.getElementById('page-market'); this._el = document.getElementById('page-market');
@@ -18,24 +19,29 @@ const PageMarket = {
const grid = document.getElementById('market-grid'); const grid = document.getElementById('market-grid');
if (!grid) return; if (!grid) return;
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);">加载中...</div>';
const isScene = this._tab === 'scenes';
const endpoint = isScene ? '/api/market/scenes' : '/api/market/dashboards';
try { try {
const res = await fetch('/api/market/dashboards'); const res = await fetch(endpoint);
const data = await res.json(); const data = await res.json();
const items = data.items || []; 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 => { 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 `<div class="glass-card theme-card"> return `<div class="glass-card theme-card">
<div class="theme-card-preview">${icon}</div> <div class="theme-card-preview">${icon}</div>
<div class="theme-card-info"> <div class="theme-card-info">
<div class="theme-card-name">${d.name||'Unnamed'}</div> <div class="theme-card-name">${d.name||'Unnamed'}</div>
<div class="theme-card-meta"> <div class="theme-card-meta">
<span>${d.category||''}</span><span>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)">${d.downloads||0} 次</span>
</div> </div>
<button class="btn btn-sm btn-primary btn-m-install" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">安装</button> <button class="btn btn-sm btn-primary ${btnClass}" data-id="${d.id}" data-file="${d.file||''}" style="margin-top:8px;width:100%">${btnLabel}</button>
</div></div>`; </div></div>`;
}).join(''); }).join('');
} catch (e) { grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; } } catch (e) { grid.innerHTML = '<div style="text-align:center;padding:40px;color:var(--danger);">加载失败</div>'; }
@@ -45,14 +51,39 @@ const PageMarket = {
document.getElementById('btn-m-login')?.addEventListener('click', () => this._showLogin()); document.getElementById('btn-m-login')?.addEventListener('click', () => this._showLogin());
document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload()); document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload());
document.getElementById('market-grid')?.addEventListener('click', async (e) => { document.getElementById('market-grid')?.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-m-install'); const btnInstall = e.target.closest('.btn-m-install');
if (!btn) return; 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 { 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(); 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'); } } 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() { _updateUI() {
@@ -74,7 +105,8 @@ const PageMarket = {
}, },
_upload() { _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) => { input.onchange = async (e) => {
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);
@@ -152,16 +184,20 @@ const PageMarket = {
_template() { _template() {
return `<div class="animated"> return `<div class="animated">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px"> <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">仪表盘市场</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">浏览并安装社区分享的仪表盘和场景 <span id="m-status" style="color:var(--text-tertiary)"> 未登录</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">发布仪表盘</button>
<button id="btn-m-login" class="btn btn-sm btn-primary">登录</button> <button id="btn-m-login" class="btn btn-sm btn-primary">登录</button>
</div> </div>
</div> </div>
<div style="display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid var(--border-subtle)">
<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-scenes" class="btn btn-sm" style="border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px">场景</button>
</div>
<div id="market-grid" class="theme-grid"></div> <div id="market-grid" class="theme-grid"></div>
</div>`; </div>`;
} }