diff --git a/app.py b/app.py index ad488ac..fdf7634 100644 --- a/app.py +++ b/app.py @@ -57,13 +57,17 @@ async def index(request: Request): @app.get("/dashboard/{theme_id}", response_class=HTMLResponse) async def dashboard_render(request: Request, theme_id: str): from models.dashboard import dashboard_manager + index_html = dashboard_manager.get_index_html(theme_id) + if index_html: + return HTMLResponse(content=index_html) theme = dashboard_manager.get(theme_id) - template_html = dashboard_manager.get_template(theme_id) - return templates.TemplateResponse("dashboard.html", { - "request": request, - "theme": theme, - "template_html": template_html, - }) + if not theme: + raise HTTPException(status_code=404, detail="Dashboard not found") + return HTMLResponse(content=f""" + + 仪表盘 "{theme['name']}" 缺少 index.html + + """) @app.get("/scene/{scene_id}", response_class=HTMLResponse) diff --git a/dashboards/gear_indicator/index.html b/dashboards/gear_indicator/index.html new file mode 100644 index 0000000..64d13df --- /dev/null +++ b/dashboards/gear_indicator/index.html @@ -0,0 +1,77 @@ + + + + + +TurboSu - 档位 + + + +
+
N
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
+
+ + + diff --git a/dashboards/gear_indicator/config.json b/dashboards/gear_indicator/manifest.json similarity index 100% rename from dashboards/gear_indicator/config.json rename to dashboards/gear_indicator/manifest.json diff --git a/dashboards/gear_indicator/template.html b/dashboards/gear_indicator/template.html deleted file mode 100644 index 49a6f7a..0000000 --- a/dashboards/gear_indicator/template.html +++ /dev/null @@ -1,35 +0,0 @@ -
-
- N -
-
-
- R -
-
-
- 1 -
-
-
- 2 -
-
-
- 3 -
-
-
- 4 -
-
-
- 5 -
-
-
- 6 -
-
-
-
diff --git a/dashboards/lap_timer/index.html b/dashboards/lap_timer/index.html new file mode 100644 index 0000000..3fe3c53 --- /dev/null +++ b/dashboards/lap_timer/index.html @@ -0,0 +1,69 @@ + + + + + +TurboSu - 圈速 + + + +
+
LAP 0
+
00:00.000
+
+
LAST
00:00.000
+
BEST
00:00.000
+
+
+ + + diff --git a/dashboards/lap_timer/config.json b/dashboards/lap_timer/manifest.json similarity index 100% rename from dashboards/lap_timer/config.json rename to dashboards/lap_timer/manifest.json diff --git a/dashboards/lap_timer/template.html b/dashboards/lap_timer/template.html deleted file mode 100644 index c397a58..0000000 --- a/dashboards/lap_timer/template.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-
LAP
-
0
-
-
-
CURRENT
-
00:00.000
-
-
-
-
LAST
-
00:00.000
-
-
-
BEST
-
00:00.000
-
-
-
diff --git a/dashboards/speedometer/index.html b/dashboards/speedometer/index.html new file mode 100644 index 0000000..7e82ced --- /dev/null +++ b/dashboards/speedometer/index.html @@ -0,0 +1,75 @@ + + + + + +TurboSu - 速度表 + + + +
+
0
+
KM/H
+
+
+ + + diff --git a/dashboards/speedometer/config.json b/dashboards/speedometer/manifest.json similarity index 100% rename from dashboards/speedometer/config.json rename to dashboards/speedometer/manifest.json diff --git a/dashboards/speedometer/template.html b/dashboards/speedometer/template.html deleted file mode 100644 index 8160741..0000000 --- a/dashboards/speedometer/template.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
0
-
KM/H
-
-
-
-
diff --git a/dashboards/tachometer/index.html b/dashboards/tachometer/index.html new file mode 100644 index 0000000..b4bfed4 --- /dev/null +++ b/dashboards/tachometer/index.html @@ -0,0 +1,72 @@ + + + + + +TurboSu - 转速表 + + + +
+ + + + +0 +RPM + +02k4k6k8k + + +
+ + + diff --git a/dashboards/tachometer/config.json b/dashboards/tachometer/manifest.json similarity index 100% rename from dashboards/tachometer/config.json rename to dashboards/tachometer/manifest.json diff --git a/dashboards/tachometer/template.html b/dashboards/tachometer/template.html deleted file mode 100644 index 429b967..0000000 --- a/dashboards/tachometer/template.html +++ /dev/null @@ -1,35 +0,0 @@ -
- - - - - - - - - - - - 0 - RPM - 0 - 8k - -
- -
-
diff --git a/models/dashboard.py b/models/dashboard.py index 767d442..6a633b2 100644 --- a/models/dashboard.py +++ b/models/dashboard.py @@ -1,9 +1,10 @@ from __future__ import annotations +import io import json -import os import shutil import uuid +import zipfile from dataclasses import dataclass, field, asdict from datetime import datetime from pathlib import Path @@ -14,87 +15,142 @@ from utils.logger import get_logger logger = get_logger(__name__) DATA_DIR = Path(__file__).resolve().parent.parent / "data" -DASHBOARDS_DIR = DATA_DIR / "dashboards" +USER_DASHBOARDS_DIR = DATA_DIR / "dashboards" BUILTIN_DASHBOARDS_DIR = Path(__file__).resolve().parent.parent / "dashboards" @dataclass class DashboardTheme: - id: str = field(default_factory=lambda: str(uuid.uuid4())) + id: str = "" name: str = "" category: str = "basic" description: str = "" author: str = "" version: str = "1.0.0" - preview: str = "" - config: dict[str, Any] = field(default_factory=dict) - created_at: str = field(default_factory=lambda: datetime.now().isoformat()) - updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) + icon: str = "📊" + aspect_ratio: str = "auto" + render_mode: str = "contain" + created_at: str = "" + updated_at: str = "" is_builtin: bool = False + dir_path: str = "" def to_dict(self) -> dict[str, Any]: - return asdict(self) + return { + "id": self.id, + "name": self.name, + "category": self.category, + "description": self.description, + "author": self.author, + "version": self.version, + "icon": self.icon, + "aspect_ratio": self.aspect_ratio, + "render_mode": self.render_mode, + "created_at": self.created_at, + "updated_at": self.updated_at, + "is_builtin": self.is_builtin, + } @classmethod - def from_dict(cls, d: dict[str, Any]) -> DashboardTheme: - return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__}) + def from_manifest(cls, manifest_path: Path, is_builtin: bool = False) -> DashboardTheme | None: + try: + with open(manifest_path, "r", encoding="utf-8") as f: + data = json.load(f) + config = data.get("config", {}) + return cls( + id=data.get("id", manifest_path.parent.name), + name=data.get("name", manifest_path.parent.name), + category=data.get("category", "basic"), + description=data.get("description", ""), + author=data.get("author", ""), + version=data.get("version", "1.0.0"), + icon=config.get("icon", "📊"), + aspect_ratio=config.get("aspect_ratio", "auto"), + render_mode=config.get("render_mode", "contain"), + created_at=data.get("created_at", ""), + updated_at=data.get("updated_at", ""), + is_builtin=is_builtin, + dir_path=str(manifest_path.parent), + ) + except Exception as e: + logger.error("Failed to load dashboard manifest %s: %s", manifest_path, e) + return None class DashboardManager: def __init__(self): - DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True) - self._cache: dict[str, DashboardTheme] = {} - self._load_builtins() + USER_DASHBOARDS_DIR.mkdir(parents=True, exist_ok=True) - def _load_builtins(self): - if not BUILTIN_DASHBOARDS_DIR.exists(): - return - for item in BUILTIN_DASHBOARDS_DIR.iterdir(): - if item.is_dir(): - cfg_file = item / "config.json" - if cfg_file.exists(): - try: - with open(cfg_file, "r", encoding="utf-8") as f: - data = json.load(f) - theme = DashboardTheme.from_dict(data) - theme.is_builtin = True - self._cache[theme.id] = theme - except Exception as e: - logger.error("Failed to load builtin dashboard %s: %s", item.name, e) - - def _load_user_dashboards(self): - for f in DASHBOARDS_DIR.glob("*.json"): - try: - with open(f, "r", encoding="utf-8") as fp: - data = json.load(fp) - theme = DashboardTheme.from_dict(data) - self._cache[theme.id] = theme - except Exception as e: - logger.error("Failed to load dashboard %s: %s", f.name, e) + def _scan_dirs(self, base_dir: Path, is_builtin: bool) -> list[DashboardTheme]: + themes = [] + if not base_dir.exists(): + return themes + for item in sorted(base_dir.iterdir()): + if not item.is_dir(): + continue + manifest = item / "manifest.json" + if manifest.exists(): + theme = DashboardTheme.from_manifest(manifest, is_builtin) + if theme: + themes.append(theme) + return themes def list_all(self, category: str | None = None) -> list[dict[str, Any]]: - self._cache.clear() - self._load_builtins() - self._load_user_dashboards() - result = [t.to_dict() for t in self._cache.values()] + themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True) + themes += self._scan_dirs(USER_DASHBOARDS_DIR, False) + result = [t.to_dict() for t in themes] if category and category != "all": result = [r for r in result if r.get("category") == category] return result def get(self, theme_id: str) -> dict[str, Any] | None: - self._cache.clear() - self._load_builtins() - self._load_user_dashboards() - theme = self._cache.get(theme_id) - return theme.to_dict() if theme else None + for base, is_builtin in [(BUILTIN_DASHBOARDS_DIR, True), (USER_DASHBOARDS_DIR, False)]: + theme_dir = base / theme_id + manifest = theme_dir / "manifest.json" + if manifest.exists(): + theme = DashboardTheme.from_manifest(manifest, is_builtin) + if theme: + return theme.to_dict() + return None - def save(self, theme: DashboardTheme) -> bool: - theme.updated_at = datetime.now().isoformat() - theme.is_builtin = False - filepath = DASHBOARDS_DIR / f"{theme.id}.json" + def get_dir(self, theme_id: str) -> Path | None: + for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]: + theme_dir = base / theme_id + if (theme_dir / "manifest.json").exists(): + return theme_dir + return None + + def get_index_html(self, theme_id: str) -> str | None: + for base in [BUILTIN_DASHBOARDS_DIR, USER_DASHBOARDS_DIR]: + idx = base / theme_id / "index.html" + if idx.exists(): + return idx.read_text(encoding="utf-8") + return None + + def save(self, theme: DashboardTheme, index_html: str = "") -> bool: + theme_dir = USER_DASHBOARDS_DIR / theme.id + theme_dir.mkdir(parents=True, exist_ok=True) + manifest = { + "id": theme.id, + "name": theme.name, + "category": theme.category, + "description": theme.description, + "author": theme.author, + "version": theme.version, + "config": { + "icon": theme.icon, + "aspect_ratio": theme.aspect_ratio, + "render_mode": theme.render_mode, + }, + "created_at": theme.created_at or datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + } try: - with open(filepath, "w", encoding="utf-8") as f: - json.dump(theme.to_dict(), f, indent=2, ensure_ascii=False) + with open(theme_dir / "manifest.json", "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + if index_html: + with open(theme_dir / "index.html", "w", encoding="utf-8") as f: + f.write(index_html) logger.info("Dashboard saved: %s", theme.id) return True except Exception as e: @@ -102,69 +158,79 @@ class DashboardManager: return False def delete(self, theme_id: str) -> bool: - self._cache.clear() - self._load_builtins() - self._load_user_dashboards() - theme = self._cache.get(theme_id) - if theme and theme.is_builtin: - logger.warning("Cannot delete builtin dashboard: %s", theme_id) - return False - filepath = DASHBOARDS_DIR / f"{theme_id}.json" - if filepath.exists(): - filepath.unlink() + theme_dir = USER_DASHBOARDS_DIR / theme_id + if theme_dir.exists(): + shutil.rmtree(theme_dir) logger.info("Dashboard deleted: %s", theme_id) - return True + return True + return False - def export_theme(self, theme_id: str) -> dict[str, Any] | None: - theme = self.get(theme_id) - if not theme: + def export_theme_zip(self, theme_id: str) -> bytes | None: + theme_dir = self.get_dir(theme_id) + if not theme_dir: return None - result = { - "type": "dashboard_theme", - "version": "1.0", - "data": theme, - "html": self._read_template_file(theme["id"]), - } - return result + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for f in sorted(theme_dir.rglob('*')): + if f.is_file(): + arcname = str(f.relative_to(theme_dir)) + zf.write(f, arcname) + logger.info("Dashboard exported as zip: %s (%d bytes)", theme_id, buf.tell()) + return buf.getvalue() - def import_theme(self, data: dict[str, Any]) -> bool: - if data.get("type") != "dashboard_theme": + def import_theme_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 zip") + return False + + manifest_data = json.loads(zf.read(manifest_name).decode('utf-8')) + theme_id = manifest_data.get('id', str(uuid.uuid4())) + + existing_dir = self.get_dir(theme_id) + if existing_dir and existing_dir.parent == BUILTIN_DASHBOARDS_DIR: + theme_id = str(uuid.uuid4()) + manifest_data['id'] = theme_id + + dest = USER_DASHBOARDS_DIR / theme_id + if dest.exists(): + shutil.rmtree(dest) + dest.mkdir(parents=True) + + prefix = manifest_name.rsplit('manifest.json', 1)[0] + for name in names: + if name == manifest_name: + continue + rel = name + if prefix and name.startswith(prefix): + rel = name[len(prefix):] + if not rel or rel.endswith('/'): + continue + out_path = dest / rel + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(zf.read(name)) + + with open(dest / 'manifest.json', 'w', encoding='utf-8') as f: + json.dump(manifest_data, f, indent=2, ensure_ascii=False) + + logger.info("Dashboard imported from zip: %s", theme_id) + return True + except Exception as e: + logger.error("Failed to import dashboard zip: %s", e) return False - theme_data = data.get("data", {}) - theme = DashboardTheme.from_dict(theme_data) - html_content = data.get("html", "") - if theme.id in self._cache: - theme.id = str(uuid.uuid4()) - success = self.save(theme) - if success and html_content: - self._save_template_file(theme.id, html_content) - return success - - def _read_template_file(self, theme_id: str) -> str: - for base in [DASHBOARDS_DIR, BUILTIN_DASHBOARDS_DIR]: - tmpl = base / theme_id / "template.html" - if tmpl.exists(): - return tmpl.read_text(encoding="utf-8") - return "" - - def _save_template_file(self, theme_id: str, content: str): - theme_dir = DASHBOARDS_DIR / theme_id - theme_dir.mkdir(parents=True, exist_ok=True) - tmpl = theme_dir / "template.html" - tmpl.write_text(content, encoding="utf-8") - - def get_template(self, theme_id: str) -> str: - return self._read_template_file(theme_id) def get_categories(self) -> list[str]: - self._cache.clear() - self._load_builtins() - self._load_user_dashboards() - cats = set() - for t in self._cache.values(): - if t.category: - cats.add(t.category) - return sorted(cats) + themes = self._scan_dirs(BUILTIN_DASHBOARDS_DIR, True) + themes += self._scan_dirs(USER_DASHBOARDS_DIR, False) + cats = sorted(set(t.category for t in themes if t.category)) + return cats dashboard_manager = DashboardManager() diff --git a/models/scene.py b/models/scene.py index 4f70a16..9af2e32 100644 --- a/models/scene.py +++ b/models/scene.py @@ -1,7 +1,10 @@ from __future__ import annotations +import io import json +import shutil import uuid +import zipfile from dataclasses import dataclass, field, asdict from datetime import datetime from pathlib import Path @@ -124,24 +127,28 @@ class SceneManager: return True return False - def export_scene(self, scene_id: str) -> dict[str, Any] | None: - scene = self.get(scene_id) - if not scene: + def export_scene_zip(self, scene_id: str) -> bytes | None: + filepath = SCENES_DIR / f"{scene_id}.json" + if not filepath.exists(): return None - return { - "type": "scene", - "version": "1.0", - "data": scene, - } + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.write(filepath, "scene.json") + logger.info("Scene exported as zip: %s", scene_id) + return buf.getvalue() - def import_scene(self, data: dict[str, Any]) -> bool: - if data.get("type") != "scene": + def import_scene_zip(self, zip_data: bytes) -> bool: + try: + with zipfile.ZipFile(io.BytesIO(zip_data), 'r') as zf: + if "scene.json" not in zf.namelist(): + logger.error("No scene.json found in zip") + return False + data = json.loads(zf.read("scene.json").decode('utf-8')) + scene = Scene.from_dict(data) + return self.save(scene) + except Exception as e: + logger.error("Failed to import scene zip: %s", e) return False - scene_data = data.get("data", {}) - if "id" not in scene_data: - scene_data["id"] = str(uuid.uuid4()) - scene = Scene.from_dict(scene_data) - return self.save(scene) scene_manager = SceneManager() diff --git a/server/api.py b/server/api.py index 2cab108..bc3170d 100644 --- a/server/api.py +++ b/server/api.py @@ -103,22 +103,23 @@ async def api_get_dashboard(theme_id: str): @router.get("/dashboards/{theme_id}/template") async def api_get_dashboard_template(theme_id: str): - tmpl = dashboard_manager.get_template(theme_id) - return {"html": tmpl} + tmpl = dashboard_manager.get_index_html(theme_id) + return {"html": tmpl or ""} @router.post("/dashboards") async def api_create_dashboard(data: dict[str, Any]): + cfg = data.get("config", {}) theme = DashboardTheme( name=data.get("name", "Untitled"), category=data.get("category", "basic"), description=data.get("description", ""), author=data.get("author", ""), - config=data.get("config", {}), + icon=cfg.get("icon", "📊"), + aspect_ratio=cfg.get("aspect_ratio", "auto"), + render_mode=cfg.get("render_mode", "contain"), ) - dashboard_manager.save(theme) - if data.get("template_html"): - dashboard_manager._save_template_file(theme.id, data["template_html"]) + dashboard_manager.save(theme, data.get("index_html", "")) return theme.to_dict() @@ -127,13 +128,17 @@ async def api_update_dashboard(theme_id: str, data: dict[str, Any]): existing = dashboard_manager.get(theme_id) if not existing: raise HTTPException(404, "Dashboard not found") - theme = DashboardTheme.from_dict(existing) - for k in ["name", "category", "description", "author", "config"]: - if k in data: - setattr(theme, k, data[k]) - dashboard_manager.save(theme) - if "template_html" in data: - dashboard_manager._save_template_file(theme.id, data["template_html"]) + theme = DashboardTheme( + id=theme_id, + name=data.get("name", existing.get("name", "")), + category=data.get("category", existing.get("category", "basic")), + description=data.get("description", existing.get("description", "")), + author=data.get("author", existing.get("author", "")), + icon=data.get("icon", existing.get("icon", "📊")), + aspect_ratio=data.get("aspect_ratio", existing.get("aspect_ratio", "auto")), + render_mode=data.get("render_mode", existing.get("render_mode", "contain")), + ) + dashboard_manager.save(theme, data.get("index_html", "")) return theme.to_dict() @@ -145,15 +150,25 @@ async def api_delete_dashboard(theme_id: str): @router.get("/dashboards/{theme_id}/export") async def api_export_dashboard(theme_id: str): - result = dashboard_manager.export_theme(theme_id) - if not result: + from fastapi.responses import Response + zip_bytes = dashboard_manager.export_theme_zip(theme_id) + if not zip_bytes: raise HTTPException(404, "Dashboard not found") - return result + theme = dashboard_manager.get(theme_id) + filename = f"{theme['id']}.tsd" if theme else "dashboard.tsd" + return Response( + content=zip_bytes, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) @router.post("/dashboards/import") -async def api_import_dashboard(data: dict[str, Any]): - ok = dashboard_manager.import_theme(data) +async def api_import_dashboard(file: UploadFile = File(...)): + if not file.filename or not file.filename.endswith('.tsd'): + raise HTTPException(400, "Only .tsd files are accepted") + zip_data = await file.read() + ok = dashboard_manager.import_theme_zip(zip_data) return {"ok": ok} @@ -224,15 +239,25 @@ async def api_delete_scene(scene_id: str): @router.get("/scenes/{scene_id}/export") async def api_export_scene(scene_id: str): - result = scene_manager.export_scene(scene_id) - if not result: + from fastapi.responses import Response + zip_bytes = scene_manager.export_scene_zip(scene_id) + if not zip_bytes: raise HTTPException(404, "Scene not found") - return result + scene = scene_manager.get(scene_id) + filename = f"{scene['id']}.tss" if scene else "scene.tss" + return Response( + content=zip_bytes, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) @router.post("/scenes/import") -async def api_import_scene(data: dict[str, Any]): - ok = scene_manager.import_scene(data) +async def api_import_scene(file: UploadFile = File(...)): + if not file.filename or not file.filename.endswith('.tss'): + raise HTTPException(400, "Only .tss files are accepted") + zip_data = await file.read() + ok = scene_manager.import_scene_zip(zip_data) return {"ok": ok} @@ -254,8 +279,11 @@ async def api_get_game(plugin_id: str): @router.post("/games/install") -async def api_install_game_plugin(data: dict[str, Any]): - ok = game_plugin_manager.install_plugin(data) +async def api_install_game_plugin(file: UploadFile = File(...)): + if not file.filename or not file.filename.endswith('.tsp'): + raise HTTPException(400, "Only .tsp files are accepted") + zip_data = await file.read() + ok = game_plugin_manager.install_plugin_zip(zip_data) return {"ok": ok} @@ -267,10 +295,15 @@ async def api_remove_game_plugin(plugin_id: str): @router.get("/games/{plugin_id}/export") async def api_export_game_plugin(plugin_id: str): - result = game_plugin_manager.export_plugin(plugin_id) - if not result: + from fastapi.responses import Response + zip_bytes = game_plugin_manager.export_plugin_zip(plugin_id) + if not zip_bytes: raise HTTPException(404, "Game plugin not found") - return result + return Response( + content=zip_bytes, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{plugin_id}.tsp"'}, + ) @router.post("/games/reload") diff --git a/server/game_manager.py b/server/game_manager.py index 6de5c94..613ca56 100644 --- a/server/game_manager.py +++ b/server/game_manager.py @@ -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() diff --git a/static/js/api.js b/static/js/api.js index a665c1d..b141d73 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -43,7 +43,6 @@ const API = { async updateDashboard(id, data) { return this.put(`/dashboards/${id}`, data); }, async deleteDashboard(id) { return this.del(`/dashboards/${id}`); }, async exportDashboard(id) { return this.get(`/dashboards/${id}/export`); }, - async importDashboard(data) { return this.post('/dashboards/import', data); }, async getScenes(gameId) { return this.get(`/scenes?game_id=${gameId || ''}`); }, async getScene(id) { return this.get(`/scenes/${id}`); }, @@ -51,13 +50,11 @@ const API = { async updateScene(id, data) { return this.put(`/scenes/${id}`, data); }, async deleteScene(id) { return this.del(`/scenes/${id}`); }, async exportScene(id) { return this.get(`/scenes/${id}/export`); }, - async importScene(data) { return this.post('/scenes/import', data); }, async startTelemetry() { return this.post('/telemetry/start'); }, async stopTelemetry() { return this.post('/telemetry/stop'); }, async getLatestTelemetry() { return this.get('/telemetry/latest'); }, - async installGamePlugin(data) { return this.post('/games/install', data); }, async removeGamePlugin(id) { return this.del(`/games/${id}`); }, async exportGamePlugin(id) { return this.get(`/games/${id}/export`); }, async reloadGamePlugins() { return this.post('/games/reload'); }, diff --git a/static/js/pages/dashboard.js b/static/js/pages/dashboard.js index cb66482..347bf6b 100644 --- a/static/js/pages/dashboard.js +++ b/static/js/pages/dashboard.js @@ -83,7 +83,33 @@ const PageDashboard = { const themeId = card.dataset.themeId; this._openDashboard(themeId); }); + gridEl.addEventListener('contextmenu', (e) => { + const card = e.target.closest('.theme-card'); + if (!card) return; + e.preventDefault(); + const themeId = card.dataset.themeId; + this._exportDashboard(themeId); + }); } + + document.getElementById('btn-import-dashboard')?.addEventListener('click', () => { + 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(); + }); }, _openDashboard(themeId) { @@ -96,9 +122,17 @@ const PageDashboard = { }); }, + _exportDashboard(themeId) { + const a = document.createElement('a'); + a.href = `/api/dashboards/${themeId}/export`; + a.download = `${themeId}.tsd`; + a.click(); + Toast.show('正在下载 .tsd 文件...', 'info'); + }, + _themeCardHtml(theme) { - const icon = theme.config?.icon || '📊'; - const aspect = theme.config?.aspect_ratio || 'auto'; + const icon = theme.icon || '📊'; + const aspect = theme.aspect_ratio || 'auto'; return `
${icon}
@@ -121,6 +155,9 @@ const PageDashboard = {
+
+ +
diff --git a/static/js/pages/scene.js b/static/js/pages/scene.js index 3b59f27..ce237fe 100644 --- a/static/js/pages/scene.js +++ b/static/js/pages/scene.js @@ -48,6 +48,24 @@ const PageScene = { _bindEvents() { document.getElementById('btn-new-scene')?.addEventListener('click', () => this._showEditor()); + document.getElementById('btn-import-scene')?.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.tss'; + 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/scenes/import', { method: 'POST', body: formData }); + const data = await res.json(); + if (data.ok) { Toast.show('场景导入成功', 'success'); this._loadScenes(); } + else Toast.show('导入失败', 'error'); + } catch (err) { Toast.show('导入失败', 'error'); } + }; + input.click(); + }); const grid = document.getElementById('scene-grid'); if (grid) { @@ -65,6 +83,9 @@ const PageScene = { } else if (action === 'delete') { e.stopPropagation(); this._deleteScene(sceneId); + } else if (action === 'export') { + e.stopPropagation(); + this._exportScene(sceneId); } }); } @@ -122,6 +143,14 @@ const PageScene = { this._loadScenes(); }, + _exportScene(sceneId) { + const a = document.createElement('a'); + a.href = `/api/scenes/${sceneId}/export`; + a.download = `${sceneId}.tss`; + a.click(); + Toast.show('正在下载 .tss 文件...', 'info'); + }, + _sceneCardHtml(scene) { const canvasCount = (scene.canvases || []).length; return ` @@ -138,6 +167,7 @@ const PageScene = {
+
`; @@ -176,9 +206,12 @@ const PageScene = { ${this._currentGameId ? `当前游戏: ${this._currentGameName}` : '请先在侧边栏选择一个游戏'}

- +
+ + +
`; } diff --git a/static/js/pages/settings.js b/static/js/pages/settings.js index 298da66..512c157 100644 --- a/static/js/pages/settings.js +++ b/static/js/pages/settings.js @@ -18,17 +18,31 @@ const PageSettings = { Toast.show('遥测端口已更新,重启监听后生效', 'info'); }); - document.getElementById('settings-server-port')?.addEventListener('change', async (e) => { - Toast.show('服务器端口修改后需要重启程序', 'warning'); - }); - document.getElementById('settings-restart-telemetry')?.addEventListener('click', async () => { await API.stopTelemetry(); await API.startTelemetry(); Toast.show('遥测监听已重启', 'success'); }); - document.getElementById('btn-import-plugin')?.addEventListener('click', () => this._importPlugin()); + document.getElementById('btn-import-plugin')?.addEventListener('click', () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.tsp'; + 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/games/install', { method: 'POST', body: formData }); + const data = await res.json(); + if (data.ok) { Toast.show('游戏插件安装成功', 'success'); this.render(); } + else Toast.show('安装失败', 'error'); + } catch (err) { Toast.show('安装失败', 'error'); } + }; + input.click(); + }); + document.getElementById('btn-reload-plugins')?.addEventListener('click', async () => { await API.reloadGamePlugins(); Toast.show('插件已重新加载', 'success'); @@ -40,11 +54,11 @@ const PageSettings = { const removeBtn = e.target.closest('.btn-remove-plugin'); if (exportBtn) { const pluginId = exportBtn.dataset.pluginId; - const data = await API.exportGamePlugin(pluginId); - if (data) { - this._downloadJson(`plugin_${pluginId}.json`, data); - Toast.show('插件已导出', 'success'); - } + const a = document.createElement('a'); + a.href = `/api/games/${pluginId}/export`; + a.download = `${pluginId}.tsp`; + a.click(); + Toast.show('正在下载 .tsp 文件...', 'info'); } if (removeBtn) { const pluginId = removeBtn.dataset.pluginId; @@ -55,40 +69,25 @@ const PageSettings = { } } }); - }, - async _importPlugin() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; - input.onchange = async (e) => { - const file = e.target.files[0]; - if (!file) return; - try { - const text = await file.text(); - const data = JSON.parse(text); - if (data.type === 'game_plugin') { - await API.installGamePlugin(data); - Toast.show('插件安装成功', 'success'); - this.render(); - } else { - Toast.show('无效的插件文件格式', 'error'); - } - } catch (err) { - Toast.show('文件解析失败: ' + err.message, 'error'); - } - }; - input.click(); - }, - - _downloadJson(filename, data) { - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + document.getElementById('btn-import-dashboard')?.addEventListener('click', () => { + 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'); + else Toast.show('导入失败', 'error'); + } catch (err) { Toast.show('导入失败', 'error'); } + }; + input.click(); + }); }, _gamePluginListHtml(games) { @@ -107,7 +106,7 @@ const PageSettings = {
- + ${!g.is_builtin ? `` : ''}
@@ -124,25 +123,16 @@ const PageSettings = {
遥测监听端口
-
游戏内设置的数据输出端口
+
游戏内设置的数据输出端口 (默认 20777)
-
-
-
Web 服务器端口
-
Web UI 服务的端口号
-
-
- -
-
重启遥测监听
-
修改端口或切换游戏后需要重启监听
+
修改端口或切换游戏后需要重启
@@ -150,22 +140,32 @@ const PageSettings = {
+
+

仪表盘管理

+
+ + + 右键点击仪表盘卡片即可导出 + +
+
+

游戏插件管理

- - + +
${this._gamePluginListHtml(games)}
- 社区开发指南:
- 1. 创建一个包含 manifest.jsonparser.py 的文件夹
- 2. manifest.json 定义游戏元信息,parser.py 实现 get_parser() 函数
- 3. get_parser() 返回对象需实现 game_id()parse(data, addr) 方法
- 4. 通过"导入插件"或放入 games/user/ 目录安装
- 5. 导出你的插件分享给社区! + 社区插件开发指南:
+ 1. 创建包含 manifest.json + parser.py 的文件夹
+ 2. manifest.json 定义元信息,parser.py 需有 get_parser() 函数
+ 3. 返回对象实现 game_id()parse(data, addr) 方法
+ 4. 打包时只需要 zip 这两个文件,重命名后缀为 .tsp 即可导入
+ 5. 完整的 TelemetryData 字段参考见 server/telemetry/data.py
@@ -174,7 +174,7 @@ const PageSettings = {
TurboSu
-
赛车遥测仪表盘 v1.0.0
+
赛车遥测仪表盘 v1.0.0 · Yei.J. (AskaEth)