refactor: standalone dashboard folders with index.html, zip-based import/export (.tsd/.tss/.tsp)

- Each dashboard is now a self-contained folder: manifest.json + index.html
- Auto-scan dashboards/ and data/dashboards/ on startup
- Export: .tsd (dashboards), .tss (scenes), .tsp (game plugins) - all zip format
- Dashboard index.html is complete standalone page (WS + data binding)
- Aspect ratio constraints handled in each dashboard's own JS
- Removed template-based dashboard rendering in favor of static serve
- Import via file upload endpoints, export via direct file download
This commit is contained in:
2026-07-25 15:35:08 +08:00
parent d61bb61a83
commit 63e3c8d607
21 changed files with 752 additions and 380 deletions
+52 -53
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
import io
import json
import shutil
import zipfile
import importlib.util
import sys
from dataclasses import dataclass, field, asdict
@@ -127,65 +130,61 @@ class GamePluginManager:
self._discover()
self._parser_cache.clear()
def install_plugin(self, data: dict[str, Any]) -> bool:
plugin_id = data.get("id", "")
if not plugin_id:
def install_plugin_zip(self, zip_data: bytes) -> bool:
try:
with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf:
names = zf.namelist()
manifest_name = None
for n in names:
if n.endswith('manifest.json'):
manifest_name = n
break
if not manifest_name:
logger.error("No manifest.json found in plugin zip")
return False
manifest = json.loads(zf.read(manifest_name).decode('utf-8'))
plugin_id = manifest.get("id", "")
if not plugin_id:
return False
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True)
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
for name in names:
if name == manifest_name or name.endswith('/'):
continue
if name.endswith('parser.py'):
out_path = dest_dir / "parser.py"
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(zf.read(name))
self._discover()
logger.info("Plugin installed from zip: %s", plugin_id)
return True
except Exception as e:
logger.error("Failed to install plugin zip: %s", e)
return False
dest_dir = USER_DIR / plugin_id
dest_dir.mkdir(parents=True, exist_ok=True)
manifest = data.get("manifest", {})
with open(dest_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
if data.get("parser_code"):
with open(dest_dir / "parser.py", "w", encoding="utf-8") as f:
f.write(data["parser_code"])
self._discover()
logger.info("Plugin installed: %s", plugin_id)
return True
def remove_plugin(self, plugin_id: str) -> bool:
gp = self._plugins.get(plugin_id)
if gp and gp.is_builtin:
logger.warning("Cannot remove builtin plugin: %s", plugin_id)
return False
import shutil
dest_dir = USER_DIR / plugin_id
if dest_dir.exists():
shutil.rmtree(dest_dir)
self._discover()
logger.info("Plugin removed: %s", plugin_id)
return True
return False
def export_plugin(self, plugin_id: str) -> dict[str, Any] | None:
def export_plugin_zip(self, plugin_id: str) -> bytes | None:
gp = self._plugins.get(plugin_id)
if not gp:
return None
parser_dir = Path(gp.manifest_path)
manifest_file = parser_dir / "manifest.json"
parser_file = parser_dir / "parser.py"
result: dict[str, Any] = {
"type": "game_plugin",
"version": "1.0",
"manifest": {},
"parser_code": "",
}
if manifest_file.exists():
with open(manifest_file, "r", encoding="utf-8") as f:
result["manifest"] = json.load(f)
if parser_file.exists():
with open(parser_file, "r", encoding="utf-8") as f:
result["parser_code"] = f.read()
return result
if not parser_dir.exists():
return None
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for f in sorted(parser_dir.rglob('*')):
if f.is_file():
zf.write(f, f.relative_to(parser_dir))
logger.info("Plugin exported as zip: %s", plugin_id)
return buf.getvalue()
game_plugin_manager = GamePluginManager()