feat: market scenes tab - upload/install with dependency check
This commit is contained in:
+87
-1
@@ -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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user