mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-06 18:22:44 +08:00
feat: enhance plugin functionality
This commit is contained in:
@@ -1 +1,17 @@
|
||||
"""
|
||||
Foxel 插件系统
|
||||
|
||||
提供 .foxpkg 插件包的安装、管理和运行时加载功能。
|
||||
"""
|
||||
|
||||
from domain.plugins.loader import PluginLoader, PluginLoadError
|
||||
from domain.plugins.service import PluginService
|
||||
from domain.plugins.startup import init_plugins, load_installed_plugins
|
||||
|
||||
__all__ = [
|
||||
"PluginLoader",
|
||||
"PluginLoadError",
|
||||
"PluginService",
|
||||
"init_plugins",
|
||||
"load_installed_plugins",
|
||||
]
|
||||
|
||||
@@ -1,76 +1,109 @@
|
||||
"""
|
||||
插件管理 API 路由
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, File, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from domain.audit import AuditAction, audit
|
||||
from domain.plugins.service import PluginService
|
||||
from domain.plugins.routes import video_player as video_player_routes
|
||||
from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut
|
||||
from domain.plugins.types import (
|
||||
PluginInstallResult,
|
||||
PluginOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
router.include_router(video_player_routes.router)
|
||||
|
||||
|
||||
@router.post("", response_model=PluginOut)
|
||||
@audit(
|
||||
action=AuditAction.CREATE,
|
||||
description="创建插件",
|
||||
body_fields=["url", "enabled"],
|
||||
)
|
||||
async def create_plugin(request: Request, payload: PluginCreate):
|
||||
return await PluginService.create(payload)
|
||||
# ========== 安装 ==========
|
||||
|
||||
|
||||
@router.post("/install", response_model=PluginInstallResult)
|
||||
@audit(action=AuditAction.CREATE, description="安装插件包")
|
||||
async def install_plugin(request: Request, file: UploadFile = File(...)):
|
||||
"""
|
||||
安装 .foxpkg 插件包
|
||||
|
||||
上传 .foxpkg 文件进行安装。
|
||||
"""
|
||||
content = await file.read()
|
||||
return await PluginService.install_package(content, file.filename or "plugin.foxpkg")
|
||||
|
||||
|
||||
# ========== 插件列表和详情 ==========
|
||||
|
||||
|
||||
@router.get("", response_model=List[PluginOut])
|
||||
@audit(action=AuditAction.READ, description="获取插件列表")
|
||||
async def list_plugins(request: Request):
|
||||
"""获取已安装的插件列表"""
|
||||
return await PluginService.list_plugins()
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
@audit(action=AuditAction.DELETE, description="删除插件")
|
||||
async def delete_plugin(request: Request, plugin_id: int):
|
||||
await PluginService.delete(plugin_id)
|
||||
@router.get("/{key_or_id}", response_model=PluginOut)
|
||||
@audit(action=AuditAction.READ, description="获取插件详情")
|
||||
async def get_plugin(request: Request, key_or_id: str):
|
||||
"""获取单个插件详情"""
|
||||
return await PluginService.get_plugin(key_or_id)
|
||||
|
||||
|
||||
# ========== 插件管理 ==========
|
||||
|
||||
|
||||
@router.delete("/{key_or_id}")
|
||||
@audit(action=AuditAction.DELETE, description="卸载插件")
|
||||
async def delete_plugin(request: Request, key_or_id: str):
|
||||
"""卸载插件"""
|
||||
await PluginService.delete(key_or_id)
|
||||
return {"code": 0, "msg": "ok"}
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", response_model=PluginOut)
|
||||
@audit(
|
||||
action=AuditAction.UPDATE,
|
||||
description="更新插件",
|
||||
body_fields=["url", "enabled"],
|
||||
)
|
||||
async def update_plugin(request: Request, plugin_id: int, payload: PluginCreate):
|
||||
return await PluginService.update(plugin_id, payload)
|
||||
# ========== 插件资源 ==========
|
||||
|
||||
|
||||
@router.post("/{plugin_id}/metadata", response_model=PluginOut)
|
||||
@audit(
|
||||
action=AuditAction.UPDATE,
|
||||
description="更新插件 manifest",
|
||||
body_fields=[
|
||||
"key",
|
||||
"name",
|
||||
"version",
|
||||
"open_app",
|
||||
"supported_exts",
|
||||
"default_bounds",
|
||||
"default_maximized",
|
||||
"icon",
|
||||
"description",
|
||||
"author",
|
||||
"website",
|
||||
"github",
|
||||
],
|
||||
)
|
||||
async def update_manifest(
|
||||
request: Request, plugin_id: int, manifest: PluginManifestUpdate = Body(...)
|
||||
):
|
||||
return await PluginService.update_manifest(plugin_id, manifest)
|
||||
@router.get("/{key_or_id}/bundle.js")
|
||||
async def get_bundle(request: Request, key_or_id: str):
|
||||
"""获取插件前端 bundle"""
|
||||
path = await PluginService.get_bundle_path(key_or_id)
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}/bundle.js")
|
||||
async def get_bundle(request: Request, plugin_id: int):
|
||||
path = await PluginService.get_bundle_path(plugin_id)
|
||||
return FileResponse(path, media_type="application/javascript", headers={"Cache-Control": "no-store"})
|
||||
@router.get("/{key}/assets/{asset_path:path}")
|
||||
async def get_asset(request: Request, key: str, asset_path: str):
|
||||
"""获取插件静态资源"""
|
||||
path = await PluginService.get_asset_path(key, asset_path)
|
||||
|
||||
# 根据扩展名确定 MIME 类型
|
||||
ext = path.suffix.lower()
|
||||
media_types = {
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".html": "text/html",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
}
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
449
domain/plugins/loader.py
Normal file
449
domain/plugins/loader.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
插件加载器模块
|
||||
|
||||
负责:
|
||||
1. .foxpkg 解包和验证
|
||||
2. 插件文件部署
|
||||
3. 后端路由动态加载
|
||||
4. 处理器动态注册
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from domain.plugins.types import (
|
||||
ManifestProcessorConfig,
|
||||
ManifestRouteConfig,
|
||||
PluginManifest,
|
||||
)
|
||||
|
||||
|
||||
class PluginLoadError(Exception):
|
||||
"""插件加载错误"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""插件加载器"""
|
||||
|
||||
PLUGINS_ROOT = Path("data/plugins")
|
||||
|
||||
# 已加载的插件模块缓存
|
||||
_loaded_modules: Dict[str, ModuleType] = {}
|
||||
# 已挂载的路由追踪
|
||||
_mounted_routers: Dict[str, List[APIRouter]] = {}
|
||||
|
||||
@classmethod
|
||||
def get_plugin_dir(cls, plugin_key: str) -> Path:
|
||||
"""获取插件目录"""
|
||||
return cls.PLUGINS_ROOT / plugin_key
|
||||
|
||||
@classmethod
|
||||
def get_manifest_path(cls, plugin_key: str) -> Path:
|
||||
"""获取插件 manifest.json 路径"""
|
||||
return cls.get_plugin_dir(plugin_key) / "manifest.json"
|
||||
|
||||
@classmethod
|
||||
def get_frontend_bundle_path(cls, plugin_key: str, entry: Optional[str] = None) -> Path:
|
||||
"""获取前端 bundle 路径"""
|
||||
plugin_dir = cls.get_plugin_dir(plugin_key)
|
||||
if entry:
|
||||
return plugin_dir / entry
|
||||
# 默认位置
|
||||
return plugin_dir / "frontend" / "index.js"
|
||||
|
||||
@classmethod
|
||||
def get_asset_path(cls, plugin_key: str, asset_path: str) -> Path:
|
||||
"""获取静态资源路径"""
|
||||
return cls.get_plugin_dir(plugin_key) / asset_path
|
||||
|
||||
# ========== 解包和验证 ==========
|
||||
|
||||
@classmethod
|
||||
def validate_manifest(cls, manifest_data: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
||||
"""验证 manifest 数据"""
|
||||
errors: List[str] = []
|
||||
|
||||
# 必需字段检查
|
||||
if not manifest_data.get("key"):
|
||||
errors.append("manifest 缺少必需字段: key")
|
||||
if not manifest_data.get("name"):
|
||||
errors.append("manifest 缺少必需字段: name")
|
||||
|
||||
# key 格式检查(Java 命名空间格式)
|
||||
key = manifest_data.get("key", "")
|
||||
if key:
|
||||
import re
|
||||
|
||||
# 格式: com.example.plugin (至少两级,每级以小写字母开头,可包含小写字母和数字)
|
||||
if not re.match(r"^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$", key):
|
||||
errors.append(
|
||||
"key 格式无效:必须使用命名空间格式(如 com.example.plugin),"
|
||||
"每个部分以小写字母开头,只能包含小写字母和数字,至少两级"
|
||||
)
|
||||
|
||||
# 版本格式检查(简单检查)
|
||||
version = manifest_data.get("version", "")
|
||||
if version and not isinstance(version, str):
|
||||
errors.append("version 必须是字符串")
|
||||
|
||||
# 验证 frontend 配置
|
||||
frontend = manifest_data.get("frontend")
|
||||
if frontend and isinstance(frontend, dict):
|
||||
if frontend.get("entry") and not isinstance(frontend["entry"], str):
|
||||
errors.append("frontend.entry 必须是字符串")
|
||||
if frontend.get("styles") is not None:
|
||||
if not isinstance(frontend["styles"], list) or not all(
|
||||
isinstance(x, str) for x in frontend["styles"]
|
||||
):
|
||||
errors.append("frontend.styles 必须是字符串数组")
|
||||
supported_exts = frontend.get("supportedExts") or frontend.get("supported_exts")
|
||||
if supported_exts and not isinstance(supported_exts, list):
|
||||
errors.append("frontend.supportedExts 必须是数组")
|
||||
use_system_window = frontend.get("useSystemWindow") or frontend.get("use_system_window")
|
||||
if use_system_window is not None and not isinstance(use_system_window, bool):
|
||||
errors.append("frontend.useSystemWindow 必须是布尔值")
|
||||
|
||||
# 验证 backend 配置
|
||||
backend = manifest_data.get("backend")
|
||||
if backend and isinstance(backend, dict):
|
||||
routes = backend.get("routes", [])
|
||||
if routes:
|
||||
for i, route in enumerate(routes):
|
||||
if not route.get("module"):
|
||||
errors.append(f"backend.routes[{i}] 缺少 module")
|
||||
if not route.get("prefix"):
|
||||
errors.append(f"backend.routes[{i}] 缺少 prefix")
|
||||
|
||||
processors = backend.get("processors", [])
|
||||
if processors:
|
||||
for i, proc in enumerate(processors):
|
||||
if not proc.get("module"):
|
||||
errors.append(f"backend.processors[{i}] 缺少 module")
|
||||
if not proc.get("type"):
|
||||
errors.append(f"backend.processors[{i}] 缺少 type")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@classmethod
|
||||
def unpack_foxpkg(
|
||||
cls, file_content: bytes, target_key: Optional[str] = None
|
||||
) -> Tuple[PluginManifest, Path]:
|
||||
"""
|
||||
解包 .foxpkg 文件
|
||||
|
||||
Args:
|
||||
file_content: .foxpkg 文件内容
|
||||
target_key: 可选,指定安装的插件 key(覆盖 manifest 中的 key)
|
||||
|
||||
Returns:
|
||||
(manifest, plugin_dir) 元组
|
||||
|
||||
Raises:
|
||||
PluginLoadError: 解包或验证失败
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_content)) as zf:
|
||||
# 读取 manifest.json
|
||||
try:
|
||||
manifest_bytes = zf.read("manifest.json")
|
||||
except KeyError:
|
||||
raise PluginLoadError("插件包缺少 manifest.json")
|
||||
|
||||
try:
|
||||
manifest_data = json.loads(manifest_bytes.decode("utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise PluginLoadError(f"manifest.json 解析失败: {e}")
|
||||
|
||||
# 验证 manifest
|
||||
valid, errors = cls.validate_manifest(manifest_data)
|
||||
if not valid:
|
||||
raise PluginLoadError(f"manifest 验证失败: {'; '.join(errors)}")
|
||||
|
||||
# 解析 manifest
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(manifest_data)
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"manifest 解析失败: {e}")
|
||||
|
||||
# 确定插件 key
|
||||
plugin_key = target_key or manifest.key
|
||||
|
||||
# 验证包内文件
|
||||
cls._validate_package_files(zf, manifest)
|
||||
|
||||
# 部署文件
|
||||
target_dir = cls.PLUGINS_ROOT / plugin_key
|
||||
if target_dir.exists():
|
||||
# 备份旧版本
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
shutil.move(str(target_dir), str(backup_dir))
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
zf.extractall(target_dir)
|
||||
except Exception as e:
|
||||
# 恢复备份
|
||||
if (cls.PLUGINS_ROOT / f"{plugin_key}.backup").exists():
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
shutil.move(str(cls.PLUGINS_ROOT / f"{plugin_key}.backup"), str(target_dir))
|
||||
raise PluginLoadError(f"文件解压失败: {e}")
|
||||
|
||||
# 清理备份
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir, ignore_errors=True)
|
||||
|
||||
return manifest, target_dir
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
raise PluginLoadError("无效的插件包格式(非 ZIP 文件)")
|
||||
|
||||
@classmethod
|
||||
def _validate_package_files(cls, zf: zipfile.ZipFile, manifest: PluginManifest) -> None:
|
||||
"""验证包内文件是否完整"""
|
||||
file_list = zf.namelist()
|
||||
|
||||
# 检查前端入口
|
||||
if manifest.frontend and manifest.frontend.entry:
|
||||
if manifest.frontend.entry not in file_list:
|
||||
raise PluginLoadError(f"前端入口文件不存在: {manifest.frontend.entry}")
|
||||
|
||||
# 检查后端模块
|
||||
if manifest.backend:
|
||||
if manifest.backend.routes:
|
||||
for route in manifest.backend.routes:
|
||||
if route.module not in file_list:
|
||||
raise PluginLoadError(f"路由模块不存在: {route.module}")
|
||||
|
||||
if manifest.backend.processors:
|
||||
for proc in manifest.backend.processors:
|
||||
if proc.module not in file_list:
|
||||
raise PluginLoadError(f"处理器模块不存在: {proc.module}")
|
||||
|
||||
# ========== 路由动态加载 ==========
|
||||
|
||||
@classmethod
|
||||
def load_route_module(cls, plugin_key: str, route_config: ManifestRouteConfig) -> APIRouter:
|
||||
"""
|
||||
动态加载插件路由模块
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
route_config: 路由配置
|
||||
|
||||
Returns:
|
||||
加载的 APIRouter
|
||||
"""
|
||||
module_path = cls.get_plugin_dir(plugin_key) / route_config.module
|
||||
|
||||
if not module_path.exists():
|
||||
raise PluginLoadError(f"路由模块不存在: {module_path}")
|
||||
|
||||
module_name = f"foxel_plugin_{plugin_key}_route_{module_path.stem}"
|
||||
|
||||
try:
|
||||
spec = spec_from_file_location(module_name, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError(f"无法加载路由模块: {module_path}")
|
||||
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 缓存模块
|
||||
cls._loaded_modules[f"{plugin_key}:route:{route_config.module}"] = module
|
||||
|
||||
# 获取 router
|
||||
router = getattr(module, "router", None)
|
||||
if router is None:
|
||||
raise PluginLoadError(f"路由模块缺少 'router' 对象: {module_path}")
|
||||
|
||||
if not isinstance(router, APIRouter):
|
||||
raise PluginLoadError(f"'router' 不是有效的 APIRouter 实例: {module_path}")
|
||||
|
||||
# 创建包装路由器添加前缀
|
||||
wrapper = APIRouter(prefix=route_config.prefix, tags=route_config.tags or [])
|
||||
wrapper.include_router(router)
|
||||
|
||||
return wrapper
|
||||
|
||||
except PluginLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"加载路由模块失败 [{module_path}]: {e}")
|
||||
|
||||
@classmethod
|
||||
def load_all_routes(cls, plugin_key: str, manifest: PluginManifest) -> List[APIRouter]:
|
||||
"""加载插件的所有路由"""
|
||||
routers: List[APIRouter] = []
|
||||
|
||||
if not manifest.backend or not manifest.backend.routes:
|
||||
return routers
|
||||
|
||||
for route_config in manifest.backend.routes:
|
||||
router = cls.load_route_module(plugin_key, route_config)
|
||||
routers.append(router)
|
||||
|
||||
cls._mounted_routers[plugin_key] = routers
|
||||
return routers
|
||||
|
||||
# ========== 处理器动态注册 ==========
|
||||
|
||||
@classmethod
|
||||
def load_processor_module(
|
||||
cls, plugin_key: str, processor_config: ManifestProcessorConfig
|
||||
) -> None:
|
||||
"""
|
||||
动态加载并注册处理器模块
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
processor_config: 处理器配置
|
||||
"""
|
||||
module_path = cls.get_plugin_dir(plugin_key) / processor_config.module
|
||||
|
||||
if not module_path.exists():
|
||||
raise PluginLoadError(f"处理器模块不存在: {module_path}")
|
||||
|
||||
module_name = f"foxel_plugin_{plugin_key}_processor_{module_path.stem}"
|
||||
|
||||
try:
|
||||
spec = spec_from_file_location(module_name, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError(f"无法加载处理器模块: {module_path}")
|
||||
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 缓存模块
|
||||
cls._loaded_modules[f"{plugin_key}:processor:{processor_config.module}"] = module
|
||||
|
||||
# 获取处理器工厂
|
||||
factory = getattr(module, "PROCESSOR_FACTORY", None)
|
||||
if factory is None:
|
||||
raise PluginLoadError(f"处理器模块缺少 'PROCESSOR_FACTORY': {module_path}")
|
||||
|
||||
# 获取配置 schema
|
||||
config_schema = getattr(module, "CONFIG_SCHEMA", [])
|
||||
processor_name = getattr(module, "PROCESSOR_NAME", processor_config.name or processor_config.type)
|
||||
supported_exts = getattr(module, "SUPPORTED_EXTS", [])
|
||||
|
||||
# 注册到处理器注册表
|
||||
from domain.processors.registry import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
processor_type = processor_config.type
|
||||
TYPE_MAP[processor_type] = factory
|
||||
|
||||
# 获取实例以读取属性
|
||||
try:
|
||||
sample = factory()
|
||||
produces_file = getattr(sample, "produces_file", False)
|
||||
supports_directory = getattr(sample, "supports_directory", False)
|
||||
except Exception:
|
||||
produces_file = False
|
||||
supports_directory = False
|
||||
|
||||
CONFIG_SCHEMAS[processor_type] = {
|
||||
"type": processor_type,
|
||||
"name": processor_name,
|
||||
"supported_exts": supported_exts,
|
||||
"config_schema": config_schema,
|
||||
"produces_file": produces_file,
|
||||
"supports_directory": supports_directory,
|
||||
"plugin": plugin_key, # 标记来源插件
|
||||
"module_path": str(module_path),
|
||||
}
|
||||
|
||||
except PluginLoadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"加载处理器模块失败 [{module_path}]: {e}")
|
||||
|
||||
@classmethod
|
||||
def load_all_processors(cls, plugin_key: str, manifest: PluginManifest) -> List[str]:
|
||||
"""加载插件的所有处理器,返回处理器类型列表"""
|
||||
processor_types: List[str] = []
|
||||
|
||||
if not manifest.backend or not manifest.backend.processors:
|
||||
return processor_types
|
||||
|
||||
for proc_config in manifest.backend.processors:
|
||||
cls.load_processor_module(plugin_key, proc_config)
|
||||
processor_types.append(proc_config.type)
|
||||
|
||||
return processor_types
|
||||
|
||||
# ========== 卸载 ==========
|
||||
|
||||
@classmethod
|
||||
def unload_plugin(cls, plugin_key: str, manifest: Optional[PluginManifest] = None) -> None:
|
||||
"""
|
||||
卸载插件的后端组件
|
||||
|
||||
Args:
|
||||
plugin_key: 插件标识
|
||||
manifest: 可选的 manifest,用于确定要卸载的组件
|
||||
"""
|
||||
# 卸载处理器
|
||||
if manifest and manifest.backend and manifest.backend.processors:
|
||||
from domain.processors.registry import CONFIG_SCHEMAS, TYPE_MAP
|
||||
|
||||
for proc_config in manifest.backend.processors:
|
||||
proc_type = proc_config.type
|
||||
if proc_type in TYPE_MAP:
|
||||
del TYPE_MAP[proc_type]
|
||||
if proc_type in CONFIG_SCHEMAS:
|
||||
del CONFIG_SCHEMAS[proc_type]
|
||||
|
||||
# 清理缓存的模块
|
||||
keys_to_remove = [k for k in cls._loaded_modules if k.startswith(f"{plugin_key}:")]
|
||||
for key in keys_to_remove:
|
||||
module = cls._loaded_modules.pop(key, None)
|
||||
if module and module.__name__ in sys.modules:
|
||||
del sys.modules[module.__name__]
|
||||
|
||||
# 清理路由追踪(注意:FastAPI 不支持动态移除路由,需要重启应用)
|
||||
cls._mounted_routers.pop(plugin_key, None)
|
||||
|
||||
@classmethod
|
||||
def delete_plugin_files(cls, plugin_key: str) -> None:
|
||||
"""删除插件文件"""
|
||||
plugin_dir = cls.get_plugin_dir(plugin_key)
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir)
|
||||
|
||||
# 同时删除备份
|
||||
backup_dir = cls.PLUGINS_ROOT / f"{plugin_key}.backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
# ========== 读取 manifest ==========
|
||||
|
||||
@classmethod
|
||||
def read_manifest(cls, plugin_key: str) -> Optional[PluginManifest]:
|
||||
"""从文件系统读取插件 manifest"""
|
||||
manifest_path = cls.get_manifest_path(plugin_key)
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return PluginManifest.model_validate(data)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,2 +0,0 @@
|
||||
"""插件专属服务端路由集合。"""
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from api.response import success
|
||||
from domain.auth.service import get_current_active_user
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/video-player",
|
||||
tags=["plugins"],
|
||||
dependencies=[Depends(get_current_active_user)],
|
||||
)
|
||||
|
||||
DATA_ROOT = Path("data/.video")
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _file_mtime_iso(path: Path) -> str:
|
||||
try:
|
||||
ts = path.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
return datetime.fromtimestamp(ts, tz=UTC).isoformat()
|
||||
|
||||
|
||||
def _extract_title(payload: Dict[str, Any]) -> str:
|
||||
detail = (payload.get("tmdb") or {}).get("detail") or {}
|
||||
if payload.get("type") == "tv":
|
||||
return str(detail.get("name") or detail.get("original_name") or "")
|
||||
return str(detail.get("title") or detail.get("original_title") or "")
|
||||
|
||||
|
||||
def _extract_year(payload: Dict[str, Any]) -> Optional[str]:
|
||||
detail = (payload.get("tmdb") or {}).get("detail") or {}
|
||||
value = detail.get("first_air_date") if payload.get("type") == "tv" else detail.get("release_date")
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
return value[:4] if len(value) >= 4 else value
|
||||
|
||||
|
||||
def _extract_genres(payload: Dict[str, Any]) -> List[str]:
|
||||
detail = (payload.get("tmdb") or {}).get("detail") or {}
|
||||
genres = detail.get("genres") or []
|
||||
out: List[str] = []
|
||||
if isinstance(genres, list):
|
||||
for g in genres:
|
||||
if isinstance(g, dict) and g.get("name"):
|
||||
out.append(str(g["name"]))
|
||||
return out
|
||||
|
||||
|
||||
def _summarize(item_id: str, payload: Dict[str, Any], mtime_iso: str) -> Dict[str, Any]:
|
||||
detail = (payload.get("tmdb") or {}).get("detail") or {}
|
||||
media_type = payload.get("type") or "unknown"
|
||||
episodes = payload.get("episodes") or []
|
||||
seasons = {e.get("season") for e in episodes if isinstance(e, dict) and e.get("season") is not None}
|
||||
|
||||
return {
|
||||
"id": item_id,
|
||||
"type": media_type,
|
||||
"title": _extract_title(payload),
|
||||
"year": _extract_year(payload),
|
||||
"overview": detail.get("overview"),
|
||||
"poster_path": detail.get("poster_path"),
|
||||
"backdrop_path": detail.get("backdrop_path"),
|
||||
"genres": _extract_genres(payload),
|
||||
"tmdb_id": (payload.get("tmdb") or {}).get("id"),
|
||||
"source_path": payload.get("source_path"),
|
||||
"scraped_at": payload.get("scraped_at"),
|
||||
"updated_at": mtime_iso,
|
||||
"episodes_count": len(episodes) if isinstance(episodes, list) else 0,
|
||||
"seasons_count": len(seasons),
|
||||
"vote_average": detail.get("vote_average"),
|
||||
"vote_count": detail.get("vote_count"),
|
||||
}
|
||||
|
||||
|
||||
def _iter_library_files() -> List[tuple[str, Path]]:
|
||||
files: List[tuple[str, Path]] = []
|
||||
for sub in ("tv", "movie"):
|
||||
folder = DATA_ROOT / sub
|
||||
if not folder.exists():
|
||||
continue
|
||||
for p in folder.glob("*.json"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
files.append((sub, p))
|
||||
return files
|
||||
|
||||
|
||||
@router.get("/library")
|
||||
async def list_library(
|
||||
q: str | None = Query(None, description="搜索关键字(标题/简介)"),
|
||||
media_type: str | None = Query(None, alias="type", description="tv 或 movie"),
|
||||
):
|
||||
items: List[Dict[str, Any]] = []
|
||||
keyword = (q or "").strip().lower()
|
||||
type_filter = (media_type or "").strip().lower()
|
||||
if type_filter and type_filter not in {"tv", "movie"}:
|
||||
raise HTTPException(status_code=400, detail="type must be tv or movie")
|
||||
|
||||
for _sub, path in _iter_library_files():
|
||||
item_id = path.stem
|
||||
try:
|
||||
payload = _read_json(path)
|
||||
except Exception:
|
||||
continue
|
||||
if type_filter and str(payload.get("type") or "").lower() != type_filter:
|
||||
continue
|
||||
summary = _summarize(item_id, payload, _file_mtime_iso(path))
|
||||
if keyword:
|
||||
haystack = f"{summary.get('title') or ''} {summary.get('overview') or ''}".lower()
|
||||
if keyword not in haystack:
|
||||
continue
|
||||
items.append(summary)
|
||||
|
||||
items.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
|
||||
return success(items)
|
||||
|
||||
|
||||
@router.get("/library/{item_id}")
|
||||
async def get_library_item(item_id: str):
|
||||
candidates = [
|
||||
DATA_ROOT / "tv" / f"{item_id}.json",
|
||||
DATA_ROOT / "movie" / f"{item_id}.json",
|
||||
]
|
||||
path = next((p for p in candidates if p.exists()), None)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
payload = _read_json(path)
|
||||
payload["id"] = item_id
|
||||
payload["updated_at"] = _file_mtime_iso(path)
|
||||
return success(payload)
|
||||
|
||||
@@ -1,138 +1,273 @@
|
||||
"""
|
||||
插件服务模块
|
||||
|
||||
负责插件的安装、卸载等管理操作
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import re
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from domain.plugins.types import PluginCreate, PluginManifestUpdate, PluginOut
|
||||
from domain.plugins.loader import PluginLoadError, PluginLoader
|
||||
from domain.plugins.types import (
|
||||
PluginInstallResult,
|
||||
PluginManifest,
|
||||
PluginOut,
|
||||
)
|
||||
from models.database import Plugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginService:
|
||||
"""插件服务"""
|
||||
|
||||
_plugins_root = Path("data/plugins")
|
||||
|
||||
@classmethod
|
||||
def _folder_name(cls, rec: Plugin) -> str:
|
||||
if rec.key:
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rec.key)
|
||||
return safe or str(rec.id)
|
||||
return str(rec.id)
|
||||
# ========== 工具方法 ==========
|
||||
|
||||
@classmethod
|
||||
def _bundle_dir_from_rec(cls, rec: Plugin) -> Path:
|
||||
return cls._plugins_root / cls._folder_name(rec) / "current"
|
||||
def _get_plugin_dir(cls, plugin_key: str) -> Path:
|
||||
"""获取插件目录"""
|
||||
return cls._plugins_root / plugin_key
|
||||
|
||||
@classmethod
|
||||
def _bundle_path_from_rec(cls, rec: Plugin) -> Path:
|
||||
return cls._bundle_dir_from_rec(rec) / "index.js"
|
||||
def _get_bundle_path(cls, rec: Plugin) -> Path:
|
||||
"""获取前端 bundle 路径"""
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
# 从 manifest 读取
|
||||
if rec.manifest:
|
||||
frontend = rec.manifest.get("frontend", {})
|
||||
entry = frontend.get("entry")
|
||||
if entry:
|
||||
return plugin_dir / entry
|
||||
# 默认位置
|
||||
return plugin_dir / "frontend" / "index.js"
|
||||
|
||||
@classmethod
|
||||
async def _download_bundle(cls, rec: Plugin, url: str) -> None:
|
||||
dest_dir = cls._bundle_dir_from_rec(rec)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_path = cls._bundle_path_from_rec(rec)
|
||||
tmp_path = dest_path.with_suffix(".tmp")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
async with aiofiles.open(tmp_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||
if not chunk:
|
||||
continue
|
||||
await f.write(chunk)
|
||||
tmp_path.replace(dest_path)
|
||||
except Exception:
|
||||
with contextlib.suppress(Exception):
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def _ensure_bundle(cls, plugin_id: int) -> Path:
|
||||
rec = await cls._get_or_404(plugin_id)
|
||||
bundle_path = cls._bundle_path_from_rec(rec)
|
||||
if bundle_path.exists():
|
||||
return bundle_path
|
||||
|
||||
legacy = cls._plugins_root / str(rec.id) / "current" / "index.js"
|
||||
if legacy.exists():
|
||||
return legacy
|
||||
|
||||
raise HTTPException(status_code=404, detail="Plugin bundle not found")
|
||||
|
||||
@classmethod
|
||||
async def get_bundle_path(cls, plugin_id: int) -> Path:
|
||||
return await cls._ensure_bundle(plugin_id)
|
||||
|
||||
@classmethod
|
||||
async def create(cls, payload: PluginCreate) -> PluginOut:
|
||||
rec = await Plugin.create(**payload.model_dump())
|
||||
try:
|
||||
await cls._download_bundle(rec, rec.url)
|
||||
except Exception as exc:
|
||||
with contextlib.suppress(Exception):
|
||||
await rec.delete()
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch plugin: {exc}")
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
@classmethod
|
||||
async def list_plugins(cls) -> list[PluginOut]:
|
||||
rows = await Plugin.all().order_by("-id")
|
||||
return [PluginOut.model_validate(r) for r in rows]
|
||||
|
||||
@classmethod
|
||||
async def _get_or_404(cls, plugin_id: int) -> Plugin:
|
||||
rec = await Plugin.get_or_none(id=plugin_id)
|
||||
async def _get_by_key_or_404(cls, key: str) -> Plugin:
|
||||
"""通过 key 获取插件,不存在则返回 404"""
|
||||
rec = await Plugin.get_or_none(key=key)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
return rec
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, plugin_id: int) -> None:
|
||||
rec = await cls._get_or_404(plugin_id)
|
||||
async def _get_by_key_or_id(cls, key_or_id: Union[str, int]) -> Plugin:
|
||||
"""通过 key 或 ID 获取插件"""
|
||||
# 尝试作为 ID
|
||||
if isinstance(key_or_id, int) or (isinstance(key_or_id, str) and key_or_id.isdigit()):
|
||||
plugin_id = int(key_or_id)
|
||||
rec = await Plugin.get_or_none(id=plugin_id)
|
||||
if rec:
|
||||
return rec
|
||||
# 尝试作为 key
|
||||
if isinstance(key_or_id, str):
|
||||
rec = await Plugin.get_or_none(key=key_or_id)
|
||||
if rec:
|
||||
return rec
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
|
||||
# ========== 安装 ==========
|
||||
|
||||
@classmethod
|
||||
async def install_package(cls, file_content: bytes, filename: str) -> PluginInstallResult:
|
||||
"""
|
||||
安装 .foxpkg 插件包
|
||||
|
||||
Args:
|
||||
file_content: 插件包内容
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
安装结果
|
||||
"""
|
||||
errors: List[str] = []
|
||||
|
||||
try:
|
||||
# 解包
|
||||
manifest, plugin_dir = PluginLoader.unpack_foxpkg(file_content)
|
||||
plugin_key = manifest.key
|
||||
|
||||
# 检查是否已存在
|
||||
existing = await Plugin.get_or_none(key=plugin_key)
|
||||
if existing:
|
||||
# 更新现有插件
|
||||
logger.info(f"更新插件: {plugin_key}")
|
||||
rec = existing
|
||||
else:
|
||||
# 创建新插件
|
||||
logger.info(f"安装新插件: {plugin_key}")
|
||||
rec = Plugin(key=plugin_key)
|
||||
|
||||
# 更新字段
|
||||
rec.name = manifest.name
|
||||
rec.version = manifest.version
|
||||
rec.description = manifest.description
|
||||
rec.author = manifest.author
|
||||
rec.website = manifest.website
|
||||
rec.github = manifest.github
|
||||
rec.license = manifest.license
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
|
||||
# 从 manifest.frontend 提取前端配置
|
||||
if manifest.frontend:
|
||||
rec.open_app = manifest.frontend.open_app or False
|
||||
rec.supported_exts = manifest.frontend.supported_exts
|
||||
rec.default_bounds = manifest.frontend.default_bounds
|
||||
rec.default_maximized = manifest.frontend.default_maximized
|
||||
rec.icon = manifest.frontend.icon
|
||||
|
||||
await rec.save()
|
||||
|
||||
# 加载后端组件(如果有)
|
||||
loaded_routes: List[str] = []
|
||||
loaded_processors: List[str] = []
|
||||
|
||||
if manifest.backend:
|
||||
# 加载路由
|
||||
if manifest.backend.routes:
|
||||
try:
|
||||
from main import app
|
||||
routers = PluginLoader.load_all_routes(plugin_key, manifest)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
loaded_routes.append(router.prefix)
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"路由加载失败: {e}")
|
||||
logger.error(f"插件 {plugin_key} 路由加载失败: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"路由加载失败: {e}")
|
||||
logger.exception(f"插件 {plugin_key} 路由加载异常")
|
||||
|
||||
# 加载处理器
|
||||
if manifest.backend.processors:
|
||||
try:
|
||||
processor_types = PluginLoader.load_all_processors(plugin_key, manifest)
|
||||
loaded_processors = processor_types
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"处理器加载失败: {e}")
|
||||
logger.error(f"插件 {plugin_key} 处理器加载失败: {e}")
|
||||
except Exception as e:
|
||||
errors.append(f"处理器加载失败: {e}")
|
||||
logger.exception(f"插件 {plugin_key} 处理器加载异常")
|
||||
|
||||
# 更新加载状态
|
||||
rec.loaded_routes = loaded_routes if loaded_routes else None
|
||||
rec.loaded_processors = loaded_processors if loaded_processors else None
|
||||
await rec.save()
|
||||
|
||||
return PluginInstallResult(
|
||||
success=True,
|
||||
plugin=PluginOut.model_validate(rec),
|
||||
message="安装成功" if not errors else "安装完成,但有部分组件加载失败",
|
||||
errors=errors if errors else None,
|
||||
)
|
||||
|
||||
except PluginLoadError as e:
|
||||
logger.error(f"插件安装失败: {e}")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=str(e),
|
||||
errors=[str(e)],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("插件安装异常")
|
||||
return PluginInstallResult(
|
||||
success=False,
|
||||
message=f"安装失败: {e}",
|
||||
errors=[str(e)],
|
||||
)
|
||||
|
||||
# ========== 查询 ==========
|
||||
|
||||
@classmethod
|
||||
async def list_plugins(cls) -> List[PluginOut]:
|
||||
"""获取所有插件列表"""
|
||||
rows = await Plugin.all().order_by("-id")
|
||||
for rec in rows:
|
||||
try:
|
||||
manifest = PluginLoader.read_manifest(rec.key)
|
||||
if manifest:
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
except Exception:
|
||||
continue
|
||||
return [PluginOut.model_validate(r) for r in rows]
|
||||
|
||||
@classmethod
|
||||
async def get_plugin(cls, key_or_id: Union[str, int]) -> PluginOut:
|
||||
"""获取单个插件详情"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
try:
|
||||
manifest = PluginLoader.read_manifest(rec.key)
|
||||
if manifest:
|
||||
rec.manifest = manifest.model_dump(mode="json")
|
||||
except Exception:
|
||||
pass
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
@classmethod
|
||||
async def get_bundle_path(cls, key_or_id: Union[str, int]) -> Path:
|
||||
"""获取插件前端 bundle 路径"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
bundle_path = cls._get_bundle_path(rec)
|
||||
if not bundle_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Plugin bundle not found")
|
||||
return bundle_path
|
||||
|
||||
@classmethod
|
||||
async def get_asset_path(cls, key: str, asset_path: str) -> Path:
|
||||
"""获取插件静态资源路径"""
|
||||
rec = await cls._get_by_key_or_404(key)
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
|
||||
# 安全检查:防止路径遍历
|
||||
asset_path = asset_path.lstrip("/")
|
||||
if ".." in asset_path:
|
||||
raise HTTPException(status_code=400, detail="Invalid asset path")
|
||||
|
||||
full_path = plugin_dir / asset_path
|
||||
if not full_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# 确保路径在插件目录内
|
||||
try:
|
||||
full_path.resolve().relative_to(plugin_dir.resolve())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid asset path")
|
||||
|
||||
return full_path
|
||||
|
||||
# ========== 管理操作 ==========
|
||||
|
||||
@classmethod
|
||||
async def delete(cls, key_or_id: Union[str, int]) -> None:
|
||||
"""删除/卸载插件"""
|
||||
rec = await cls._get_by_key_or_id(key_or_id)
|
||||
|
||||
# 获取 manifest 用于卸载组件
|
||||
manifest: Optional[PluginManifest] = None
|
||||
if rec.manifest:
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(rec.manifest)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 卸载后端组件
|
||||
if manifest:
|
||||
PluginLoader.unload_plugin(rec.key, manifest)
|
||||
|
||||
# 删除数据库记录
|
||||
await rec.delete()
|
||||
|
||||
# 删除文件
|
||||
with contextlib.suppress(Exception):
|
||||
dirs = {cls._bundle_dir_from_rec(rec).parent, cls._plugins_root / str(rec.id)}
|
||||
for plugin_dir in dirs:
|
||||
plugin_dir = cls._get_plugin_dir(rec.key)
|
||||
if plugin_dir.exists():
|
||||
shutil.rmtree(plugin_dir)
|
||||
|
||||
@classmethod
|
||||
async def update(cls, plugin_id: int, payload: PluginCreate) -> PluginOut:
|
||||
rec = await cls._get_or_404(plugin_id)
|
||||
url_changed = rec.url != payload.url
|
||||
if url_changed:
|
||||
try:
|
||||
await cls._download_bundle(rec, payload.url)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to fetch plugin: {exc}")
|
||||
rec.url = payload.url
|
||||
rec.enabled = payload.enabled
|
||||
await rec.save()
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
@classmethod
|
||||
async def update_manifest(
|
||||
cls, plugin_id: int, manifest: PluginManifestUpdate
|
||||
) -> PluginOut:
|
||||
rec = await cls._get_or_404(plugin_id)
|
||||
old_dir = cls._bundle_dir_from_rec(rec).parent
|
||||
updates = manifest.model_dump(exclude_none=True)
|
||||
if updates:
|
||||
for key, value in updates.items():
|
||||
setattr(rec, key, value)
|
||||
await rec.save()
|
||||
new_dir = cls._bundle_dir_from_rec(rec).parent
|
||||
if rec.key and new_dir != old_dir:
|
||||
candidate_dir = old_dir if old_dir.exists() else (cls._plugins_root / str(rec.id))
|
||||
if candidate_dir.exists():
|
||||
new_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
with contextlib.suppress(Exception):
|
||||
if new_dir.exists():
|
||||
shutil.rmtree(new_dir)
|
||||
shutil.move(str(candidate_dir), str(new_dir))
|
||||
return PluginOut.model_validate(rec)
|
||||
logger.info(f"插件 {rec.key} 已卸载")
|
||||
|
||||
116
domain/plugins/startup.py
Normal file
116
domain/plugins/startup.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
插件启动加载模块
|
||||
|
||||
负责在应用启动时加载所有已安装的插件
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, List, Tuple
|
||||
|
||||
from domain.plugins.loader import PluginLoadError, PluginLoader
|
||||
from domain.plugins.types import PluginManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def load_installed_plugins(app: "FastAPI") -> Tuple[int, List[str]]:
|
||||
"""
|
||||
加载所有已安装的插件
|
||||
|
||||
Args:
|
||||
app: FastAPI 应用实例
|
||||
|
||||
Returns:
|
||||
(成功加载数量, 错误列表)
|
||||
"""
|
||||
from models.database import Plugin
|
||||
|
||||
errors: List[str] = []
|
||||
loaded_count = 0
|
||||
|
||||
try:
|
||||
plugins = await Plugin.all()
|
||||
except Exception as e:
|
||||
logger.error(f"查询插件列表失败: {e}")
|
||||
return 0, [f"查询插件列表失败: {e}"]
|
||||
|
||||
for plugin in plugins:
|
||||
if not plugin.key:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取 manifest
|
||||
manifest = None
|
||||
if plugin.manifest:
|
||||
try:
|
||||
manifest = PluginManifest.model_validate(plugin.manifest)
|
||||
except Exception:
|
||||
# 尝试从文件系统读取
|
||||
manifest = PluginLoader.read_manifest(plugin.key)
|
||||
else:
|
||||
manifest = PluginLoader.read_manifest(plugin.key)
|
||||
|
||||
if not manifest:
|
||||
logger.warning(f"插件 {plugin.key} 缺少 manifest,跳过加载")
|
||||
continue
|
||||
|
||||
# 加载后端路由
|
||||
loaded_routes: List[str] = []
|
||||
if manifest.backend and manifest.backend.routes:
|
||||
try:
|
||||
routers = PluginLoader.load_all_routes(plugin.key, manifest)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
loaded_routes.append(router.prefix)
|
||||
logger.info(f"插件 {plugin.key} 加载了 {len(routers)} 个路由")
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"插件 {plugin.key} 路由加载失败: {e}")
|
||||
logger.error(f"插件 {plugin.key} 路由加载失败: {e}")
|
||||
|
||||
# 加载处理器
|
||||
loaded_processors: List[str] = []
|
||||
if manifest.backend and manifest.backend.processors:
|
||||
try:
|
||||
processor_types = PluginLoader.load_all_processors(plugin.key, manifest)
|
||||
loaded_processors = processor_types
|
||||
logger.info(f"插件 {plugin.key} 注册了 {len(processor_types)} 个处理器")
|
||||
except PluginLoadError as e:
|
||||
errors.append(f"插件 {plugin.key} 处理器加载失败: {e}")
|
||||
logger.error(f"插件 {plugin.key} 处理器加载失败: {e}")
|
||||
|
||||
# 更新数据库记录
|
||||
plugin.loaded_routes = loaded_routes if loaded_routes else None
|
||||
plugin.loaded_processors = loaded_processors if loaded_processors else None
|
||||
await plugin.save()
|
||||
|
||||
loaded_count += 1
|
||||
logger.info(f"插件 {plugin.key} 加载完成")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"插件 {plugin.key} 加载异常: {e}"
|
||||
errors.append(error_msg)
|
||||
logger.exception(error_msg)
|
||||
|
||||
return loaded_count, errors
|
||||
|
||||
|
||||
async def init_plugins(app: "FastAPI") -> None:
|
||||
"""
|
||||
初始化插件系统
|
||||
|
||||
在应用启动时调用
|
||||
"""
|
||||
logger.info("开始加载已安装插件...")
|
||||
|
||||
loaded_count, errors = await load_installed_plugins(app)
|
||||
|
||||
if errors:
|
||||
logger.warning(f"插件加载完成,共 {loaded_count} 个成功,{len(errors)} 个错误")
|
||||
for error in errors:
|
||||
logger.warning(f" - {error}")
|
||||
else:
|
||||
logger.info(f"插件加载完成,共 {loaded_count} 个插件")
|
||||
|
||||
@@ -1,48 +1,119 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PluginCreate(BaseModel):
|
||||
url: str = Field(min_length=1)
|
||||
enabled: bool = True
|
||||
# ========== Manifest 相关类型 ==========
|
||||
|
||||
|
||||
class PluginManifestUpdate(BaseModel):
|
||||
class ManifestFrontend(BaseModel):
|
||||
"""manifest.json 中的 frontend 配置"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
key: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
entry: Optional[str] = Field(default=None, description="前端入口文件路径")
|
||||
styles: Optional[List[str]] = Field(default=None, description="前端样式文件路径列表(相对插件根目录)")
|
||||
open_app: Optional[bool] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("open_app", "openApp"),
|
||||
alias="openApp",
|
||||
description="是否支持独立打开",
|
||||
)
|
||||
supported_exts: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("supported_exts", "supportedExts"),
|
||||
alias="supportedExts",
|
||||
description="支持的文件扩展名列表",
|
||||
)
|
||||
default_bounds: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("default_bounds", "defaultBounds"),
|
||||
alias="defaultBounds",
|
||||
description="默认窗口尺寸",
|
||||
)
|
||||
default_maximized: Optional[bool] = Field(
|
||||
default=None,
|
||||
validation_alias=AliasChoices("default_maximized", "defaultMaximized"),
|
||||
alias="defaultMaximized",
|
||||
description="是否默认最大化",
|
||||
)
|
||||
icon: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
github: Optional[str] = None
|
||||
icon: Optional[str] = Field(default=None, description="图标路径")
|
||||
use_system_window: Optional[bool] = Field(
|
||||
default=None,
|
||||
alias="useSystemWindow",
|
||||
description="是否使用系统窗口",
|
||||
)
|
||||
|
||||
|
||||
class ManifestRouteConfig(BaseModel):
|
||||
"""manifest.json 中的路由配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
module: str = Field(..., description="路由模块路径")
|
||||
prefix: str = Field(..., description="路由前缀")
|
||||
tags: Optional[List[str]] = Field(default=None, description="API 标签")
|
||||
|
||||
|
||||
class ManifestProcessorConfig(BaseModel):
|
||||
"""manifest.json 中的处理器配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
module: str = Field(..., description="处理器模块路径")
|
||||
type: str = Field(..., description="处理器类型标识")
|
||||
name: Optional[str] = Field(default=None, description="处理器显示名称")
|
||||
|
||||
|
||||
class ManifestBackend(BaseModel):
|
||||
"""manifest.json 中的 backend 配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
routes: Optional[List[ManifestRouteConfig]] = Field(default=None, description="路由列表")
|
||||
processors: Optional[List[ManifestProcessorConfig]] = Field(
|
||||
default=None, description="处理器列表"
|
||||
)
|
||||
|
||||
|
||||
class ManifestDependencies(BaseModel):
|
||||
"""manifest.json 中的依赖配置"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
python: Optional[str] = Field(default=None, description="Python 版本要求")
|
||||
packages: Optional[List[str]] = Field(default=None, description="Python 包依赖列表")
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
"""完整的 manifest.json 结构"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
||||
|
||||
foxpkg: str = Field(default="1.0", description="foxpkg 格式版本")
|
||||
key: str = Field(..., min_length=1, description="插件唯一标识")
|
||||
name: str = Field(..., min_length=1, description="插件名称")
|
||||
version: str = Field(default="1.0.0", description="插件版本")
|
||||
description: Optional[str] = Field(default=None, description="插件描述")
|
||||
i18n: Optional[Dict[str, Dict[str, str]]] = Field(
|
||||
default=None,
|
||||
description="多语言信息(name/description),例如:{'en': {'name': '...', 'description': '...'}}",
|
||||
)
|
||||
author: Optional[str] = Field(default=None, description="作者")
|
||||
website: Optional[str] = Field(default=None, description="网站")
|
||||
github: Optional[str] = Field(default=None, description="GitHub 地址")
|
||||
license: Optional[str] = Field(default=None, description="许可证")
|
||||
|
||||
frontend: Optional[ManifestFrontend] = Field(default=None, description="前端配置")
|
||||
backend: Optional[ManifestBackend] = Field(default=None, description="后端配置")
|
||||
dependencies: Optional[ManifestDependencies] = Field(default=None, description="依赖配置")
|
||||
|
||||
|
||||
# ========== API 请求/响应类型 ==========
|
||||
|
||||
|
||||
class PluginOut(BaseModel):
|
||||
"""插件输出模型"""
|
||||
|
||||
id: int
|
||||
url: str
|
||||
enabled: bool
|
||||
key: str
|
||||
open_app: bool = False
|
||||
key: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
supported_exts: Optional[List[str]] = None
|
||||
@@ -53,5 +124,20 @@ class PluginOut(BaseModel):
|
||||
author: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
github: Optional[str] = None
|
||||
license: Optional[str] = None
|
||||
|
||||
# 新增字段
|
||||
manifest: Optional[Dict[str, Any]] = None
|
||||
loaded_routes: Optional[List[str]] = None
|
||||
loaded_processors: Optional[List[str]] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PluginInstallResult(BaseModel):
|
||||
"""安装结果"""
|
||||
|
||||
success: bool
|
||||
plugin: Optional[PluginOut] = None
|
||||
message: Optional[str] = None
|
||||
errors: Optional[List[str]] = None
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from domain.virtual_fs.service import VirtualFSService
|
||||
from domain.virtual_fs.thumbnail import VIDEO_EXT, is_video_filename
|
||||
|
||||
|
||||
DATA_ROOT = Path("data/.video")
|
||||
TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
||||
|
||||
|
||||
def _sha1(text: str) -> str:
|
||||
return hashlib.sha1(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _store_path(media_type: str, source_path: str) -> Path:
|
||||
subdir = "tv" if media_type == "tv" else "movie"
|
||||
return DATA_ROOT / subdir / f"{_sha1(source_path)}.json"
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
_CLEAN_TAGS_RE = re.compile(
|
||||
r"\b("
|
||||
r"2160p|1080p|720p|480p|4k|hdr|dv|dolby|atmos|"
|
||||
r"x264|x265|h264|h265|hevc|av1|aac|dts|flac|"
|
||||
r"bluray|bdrip|web[- ]?dl|webrip|dvdrip|remux|proper|repack"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _clean_query_name(raw: str) -> str:
|
||||
name = raw
|
||||
name = name.replace(".", " ").replace("_", " ")
|
||||
name = re.sub(r"\[[^\]]*\]", " ", name)
|
||||
name = re.sub(r"\([^\)]*\)", " ", name)
|
||||
name = _CLEAN_TAGS_RE.sub(" ", name)
|
||||
name = re.sub(r"\s+", " ", name).strip()
|
||||
return name
|
||||
|
||||
|
||||
def _guess_name_from_path(path: str, is_dir: bool) -> str:
|
||||
norm = path.rstrip("/") if is_dir else path
|
||||
p = Path(norm)
|
||||
raw = p.name if is_dir else p.stem
|
||||
return _clean_query_name(raw)
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in {"1", "true", "yes", "y", "on"}:
|
||||
return True
|
||||
if v in {"0", "false", "no", "n", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
_SXXEYY_RE = re.compile(r"[Ss](\d{1,2})\s*[.\-_ ]*\s*[Ee](\d{1,3})")
|
||||
_X_RE = re.compile(r"(\d{1,2})x(\d{1,3})", re.IGNORECASE)
|
||||
_CN_EP_RE = re.compile(r"第\s*(\d{1,3})\s*[集话]")
|
||||
_CN_SEASON_RE = re.compile(r"第\s*(\d{1,2})\s*季")
|
||||
_SEASON_WORD_RE = re.compile(r"Season\s*(\d{1,2})", re.IGNORECASE)
|
||||
_S_RE = re.compile(r"[Ss](\d{1,2})")
|
||||
|
||||
|
||||
def _parse_season_episode(rel_path: str) -> Tuple[Optional[int], Optional[int]]:
|
||||
stem = Path(rel_path).stem
|
||||
|
||||
m = _SXXEYY_RE.search(stem) or _SXXEYY_RE.search(rel_path)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
m = _X_RE.search(stem)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
m = _CN_EP_RE.search(stem)
|
||||
if m:
|
||||
episode = int(m.group(1))
|
||||
season = None
|
||||
for part in reversed(Path(rel_path).parts[:-1]):
|
||||
sm = _CN_SEASON_RE.search(part) or _SEASON_WORD_RE.search(part) or _S_RE.search(part)
|
||||
if sm:
|
||||
season = int(sm.group(1))
|
||||
break
|
||||
return season or 1, episode
|
||||
|
||||
m = re.match(r"^(\d{1,3})(?!\d)", stem)
|
||||
if m:
|
||||
episode = int(m.group(1))
|
||||
season = None
|
||||
for part in reversed(Path(rel_path).parts[:-1]):
|
||||
sm = _CN_SEASON_RE.search(part) or _SEASON_WORD_RE.search(part) or _S_RE.search(part)
|
||||
if sm:
|
||||
season = int(sm.group(1))
|
||||
break
|
||||
return season or 1, episode
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
class TMDBClient:
|
||||
def __init__(self, access_token: str | None, api_key: str | None):
|
||||
self._access_token = access_token
|
||||
self._api_key = api_key
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "TMDBClient":
|
||||
access_token = os.getenv("TMDB_ACCESS_TOKEN")
|
||||
api_key = os.getenv("TMDB_API_KEY")
|
||||
if not access_token and not api_key:
|
||||
raise RuntimeError("缺少 TMDB_ACCESS_TOKEN 或 TMDB_API_KEY")
|
||||
return cls(access_token=access_token, api_key=api_key)
|
||||
|
||||
def _headers(self) -> dict:
|
||||
headers = {"Accept": "application/json"}
|
||||
if self._access_token:
|
||||
headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
return headers
|
||||
|
||||
def _merge_params(self, params: dict) -> dict:
|
||||
merged = dict(params or {})
|
||||
if self._api_key:
|
||||
merged.setdefault("api_key", self._api_key)
|
||||
return merged
|
||||
|
||||
async def get(self, path: str, params: dict) -> dict:
|
||||
url = f"{TMDB_BASE_URL}{path}"
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.get(url, headers=self._headers(), params=self._merge_params(params))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
class VideoLibraryProcessor:
|
||||
name = "影视入库"
|
||||
supported_exts = sorted(VIDEO_EXT)
|
||||
config_schema = [
|
||||
{
|
||||
"key": "name",
|
||||
"label": "手动名称(可选)",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"placeholder": "留空则从路径提取",
|
||||
},
|
||||
{
|
||||
"key": "language",
|
||||
"label": "语言",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"default": "zh-CN",
|
||||
},
|
||||
{
|
||||
"key": "include_episodes",
|
||||
"label": "电视剧:保存每集",
|
||||
"type": "select",
|
||||
"required": False,
|
||||
"default": 1,
|
||||
"options": [
|
||||
{"label": "是", "value": 1},
|
||||
{"label": "否", "value": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
produces_file = False
|
||||
supports_directory = True
|
||||
requires_input_bytes = False
|
||||
|
||||
async def process(self, input_bytes: bytes, path: str, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
tmdb = TMDBClient.from_env()
|
||||
is_dir = await VirtualFSService.path_is_directory(path)
|
||||
language = str(config.get("language") or "zh-CN")
|
||||
manual_name = str(config.get("name") or "").strip()
|
||||
query_name = manual_name or _guess_name_from_path(path, is_dir=is_dir)
|
||||
scraped_at = datetime.now(UTC).isoformat()
|
||||
|
||||
if is_dir:
|
||||
payload, saved_to = await self._process_tv_dir(tmdb, path, query_name, language, scraped_at, config)
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "tv",
|
||||
"path": path,
|
||||
"tmdb_id": payload.get("tmdb", {}).get("id"),
|
||||
"saved_to": str(saved_to),
|
||||
}
|
||||
|
||||
payload, saved_to = await self._process_movie_file(tmdb, path, query_name, language, scraped_at)
|
||||
return {
|
||||
"ok": True,
|
||||
"type": "movie",
|
||||
"path": path,
|
||||
"tmdb_id": payload.get("tmdb", {}).get("id"),
|
||||
"saved_to": str(saved_to),
|
||||
}
|
||||
|
||||
async def _process_movie_file(
|
||||
self,
|
||||
tmdb: TMDBClient,
|
||||
path: str,
|
||||
query_name: str,
|
||||
language: str,
|
||||
scraped_at: str,
|
||||
) -> Tuple[dict, Path]:
|
||||
search = await tmdb.get("/search/movie", {"query": query_name, "language": language})
|
||||
results = search.get("results") or []
|
||||
if not results:
|
||||
raise RuntimeError(f"未找到电影条目:{query_name}")
|
||||
|
||||
chosen = results[0] or {}
|
||||
movie_id = chosen.get("id")
|
||||
if not movie_id:
|
||||
raise RuntimeError("TMDB 搜索结果缺少 id")
|
||||
|
||||
detail = await tmdb.get(
|
||||
f"/movie/{movie_id}",
|
||||
{
|
||||
"language": language,
|
||||
"append_to_response": "credits,images,external_ids,videos",
|
||||
},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"type": "movie",
|
||||
"source_path": path,
|
||||
"query": {"name": query_name, "language": language},
|
||||
"scraped_at": scraped_at,
|
||||
"tmdb": {
|
||||
"id": movie_id,
|
||||
"search": {"page": search.get("page"), "total_results": search.get("total_results"), "results": results[:5]},
|
||||
"detail": detail,
|
||||
},
|
||||
}
|
||||
saved_to = _store_path("movie", path)
|
||||
_write_json(saved_to, payload)
|
||||
return payload, saved_to
|
||||
|
||||
async def _process_tv_dir(
|
||||
self,
|
||||
tmdb: TMDBClient,
|
||||
path: str,
|
||||
query_name: str,
|
||||
language: str,
|
||||
scraped_at: str,
|
||||
config: Dict[str, Any],
|
||||
) -> Tuple[dict, Path]:
|
||||
search = await tmdb.get("/search/tv", {"query": query_name, "language": language})
|
||||
results = search.get("results") or []
|
||||
if not results:
|
||||
raise RuntimeError(f"未找到电视剧条目:{query_name}")
|
||||
|
||||
chosen = results[0] or {}
|
||||
tv_id = chosen.get("id")
|
||||
if not tv_id:
|
||||
raise RuntimeError("TMDB 搜索结果缺少 id")
|
||||
|
||||
detail = await tmdb.get(
|
||||
f"/tv/{tv_id}",
|
||||
{
|
||||
"language": language,
|
||||
"append_to_response": "credits,images,external_ids,videos",
|
||||
},
|
||||
)
|
||||
|
||||
include_episodes = _as_bool(config.get("include_episodes"), True)
|
||||
episodes: List[dict] = []
|
||||
seasons_detail: Dict[str, Any] = {}
|
||||
if include_episodes:
|
||||
episodes = await self._collect_episode_files(path)
|
||||
seasons = sorted({ep["season"] for ep in episodes if ep.get("season") is not None})
|
||||
for season in seasons:
|
||||
seasons_detail[str(season)] = await tmdb.get(
|
||||
f"/tv/{tv_id}/season/{int(season)}",
|
||||
{"language": language},
|
||||
)
|
||||
self._attach_tmdb_episode_detail(episodes, seasons_detail)
|
||||
|
||||
payload = {
|
||||
"type": "tv",
|
||||
"source_path": path,
|
||||
"query": {"name": query_name, "language": language},
|
||||
"scraped_at": scraped_at,
|
||||
"tmdb": {
|
||||
"id": tv_id,
|
||||
"search": {"page": search.get("page"), "total_results": search.get("total_results"), "results": results[:5]},
|
||||
"detail": detail,
|
||||
"seasons": seasons_detail,
|
||||
},
|
||||
"episodes": episodes,
|
||||
}
|
||||
|
||||
saved_to = _store_path("tv", path)
|
||||
_write_json(saved_to, payload)
|
||||
return payload, saved_to
|
||||
|
||||
async def _collect_episode_files(self, dir_path: str) -> List[dict]:
|
||||
adapter_instance, adapter_model, root, rel = await VirtualFSService.resolve_adapter_and_rel(dir_path)
|
||||
rel = rel.rstrip("/")
|
||||
list_dir = await VirtualFSService._ensure_method(adapter_instance, "list_dir")
|
||||
|
||||
stack: List[str] = [rel]
|
||||
page_size = 200
|
||||
out: List[dict] = []
|
||||
|
||||
while stack:
|
||||
current_rel = stack.pop()
|
||||
page = 1
|
||||
while True:
|
||||
entries, total = await list_dir(root, current_rel, page, page_size, "name", "asc")
|
||||
entries = entries or []
|
||||
if not entries and (total or 0) == 0:
|
||||
break
|
||||
|
||||
for entry in entries:
|
||||
name = entry.get("name")
|
||||
if not name:
|
||||
continue
|
||||
child_rel = VirtualFSService._join_rel(current_rel, name)
|
||||
if entry.get("is_dir"):
|
||||
stack.append(child_rel.rstrip("/"))
|
||||
continue
|
||||
if not is_video_filename(name):
|
||||
continue
|
||||
|
||||
absolute_path = VirtualFSService._build_absolute_path(adapter_model.path, child_rel)
|
||||
rel_in_show = child_rel
|
||||
if rel and child_rel.startswith(rel.rstrip("/") + "/"):
|
||||
rel_in_show = child_rel[len(rel.rstrip("/")) + 1 :]
|
||||
|
||||
season, episode = _parse_season_episode(rel_in_show)
|
||||
out.append(
|
||||
{
|
||||
"path": absolute_path,
|
||||
"rel": rel_in_show,
|
||||
"name": name,
|
||||
"size": entry.get("size"),
|
||||
"mtime": entry.get("mtime"),
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
)
|
||||
|
||||
if total is None or page * page_size >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return out
|
||||
|
||||
def _attach_tmdb_episode_detail(self, episodes: List[dict], seasons_detail: Dict[str, Any]) -> None:
|
||||
episode_maps: Dict[str, Dict[int, Any]] = {}
|
||||
for season_str, season_payload in (seasons_detail or {}).items():
|
||||
items = (season_payload or {}).get("episodes") or []
|
||||
m: Dict[int, Any] = {}
|
||||
for item in items:
|
||||
try:
|
||||
number = int(item.get("episode_number"))
|
||||
except Exception:
|
||||
continue
|
||||
m[number] = item
|
||||
episode_maps[season_str] = m
|
||||
|
||||
for ep in episodes:
|
||||
season = ep.get("season")
|
||||
episode = ep.get("episode")
|
||||
if season is None or episode is None:
|
||||
continue
|
||||
m = episode_maps.get(str(season))
|
||||
if not m:
|
||||
continue
|
||||
detail = m.get(int(episode))
|
||||
if detail:
|
||||
ep["tmdb_episode"] = detail
|
||||
|
||||
|
||||
PROCESSOR_TYPE = "video_library"
|
||||
PROCESSOR_NAME = VideoLibraryProcessor.name
|
||||
SUPPORTED_EXTS = VideoLibraryProcessor.supported_exts
|
||||
CONFIG_SCHEMA = VideoLibraryProcessor.config_schema
|
||||
PROCESSOR_FACTORY = lambda: VideoLibraryProcessor()
|
||||
10
main.py
10
main.py
@@ -52,10 +52,19 @@ async def spa_fallback_middleware(request: Request, call_next):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs("data/db", exist_ok=True)
|
||||
os.makedirs("data/plugins", exist_ok=True)
|
||||
await init_db()
|
||||
await runtime_registry.refresh()
|
||||
await ConfigService.set("APP_VERSION", VERSION)
|
||||
await task_queue_service.start_worker()
|
||||
|
||||
# 加载已安装的插件
|
||||
from domain.plugins.startup import init_plugins
|
||||
await init_plugins(app)
|
||||
|
||||
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
|
||||
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -86,7 +95,6 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
@@ -168,24 +168,28 @@ class ShareLink(Model):
|
||||
|
||||
class Plugin(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
url = fields.CharField(max_length=2048)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
|
||||
open_app = fields.BooleanField(default=False)
|
||||
|
||||
key = fields.CharField(max_length=100, null=True)
|
||||
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
version = fields.CharField(max_length=50, null=True)
|
||||
supported_exts = fields.JSONField(null=True)
|
||||
|
||||
default_bounds = fields.JSONField(null=True)
|
||||
default_maximized = fields.BooleanField(null=True)
|
||||
|
||||
icon = fields.CharField(max_length=2048, null=True)
|
||||
description = fields.TextField(null=True)
|
||||
author = fields.CharField(max_length=255, null=True)
|
||||
website = fields.CharField(max_length=2048, null=True)
|
||||
github = fields.CharField(max_length=2048, null=True)
|
||||
license = fields.CharField(max_length=100, null=True)
|
||||
|
||||
# 完整 manifest 存储
|
||||
manifest = fields.JSONField(null=True)
|
||||
|
||||
# 前端相关配置(从 manifest.frontend 提取)
|
||||
open_app = fields.BooleanField(default=False)
|
||||
supported_exts = fields.JSONField(null=True)
|
||||
default_bounds = fields.JSONField(null=True)
|
||||
default_maximized = fields.BooleanField(null=True)
|
||||
icon = fields.CharField(max_length=2048, null=True)
|
||||
|
||||
# 已加载的组件追踪
|
||||
loaded_routes = fields.JSONField(null=True) # ["/api/plugins/xxx", ...]
|
||||
loaded_processors = fields.JSONField(null=True) # ["processor_type", ...]
|
||||
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
142
web/bun.lock
142
web/bun.lock
@@ -7,11 +7,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"antd": "6",
|
||||
"artplayer": "^5.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -337,8 +335,6 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
@@ -367,12 +363,6 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.51.0", "", { "dependencies": { "@typescript-eslint/types": "8.51.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg=="],
|
||||
|
||||
"@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.19", "", {}, "sha512-AYxzFUBkZrhtExb2QC0C4lFH2+BSx6JVId9iqeGHakBuosqiQHUQaNZCvIBeM97Ucp+nJ22flOh8FBT2pKRRAA=="],
|
||||
|
||||
"@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.1.5", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~9.0.1", "rehype-attr": "~3.0.1", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg=="],
|
||||
|
||||
"@uiw/react-md-editor": ["@uiw/react-md-editor@4.0.11", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.0.6", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-F0OR5O1v54EkZYvJj3ew0I7UqLiPeU34hMAY4MdXS3hI86rruYi5DHVkG/VuvLkUZW7wIETM2QFtZ459gKIjQA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
|
||||
@@ -397,10 +387,6 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
|
||||
|
||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
@@ -439,8 +425,6 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.3.0", "", {}, "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
@@ -457,14 +441,10 @@
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -515,50 +495,22 @@
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
|
||||
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
|
||||
|
||||
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
|
||||
|
||||
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
|
||||
|
||||
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
|
||||
|
||||
"hast-util-parse-selector": ["hast-util-parse-selector@3.1.1", "", { "dependencies": { "@types/hast": "^2.0.0" } }, "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA=="],
|
||||
|
||||
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
|
||||
|
||||
"hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
|
||||
|
||||
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hastscript": ["hastscript@7.2.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^3.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
@@ -615,26 +567,10 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
@@ -653,20 +589,6 @@
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
@@ -717,8 +639,6 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -731,10 +651,6 @@
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -763,38 +679,10 @@
|
||||
|
||||
"react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],
|
||||
|
||||
"refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="],
|
||||
|
||||
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
||||
|
||||
"rehype-attr": ["rehype-attr@3.0.3", "", { "dependencies": { "unified": "~11.0.0", "unist-util-visit": "~5.0.0" } }, "sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw=="],
|
||||
|
||||
"rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="],
|
||||
|
||||
"rehype-ignore": ["rehype-ignore@2.0.3", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg=="],
|
||||
|
||||
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
|
||||
|
||||
"rehype-prism-plus": ["rehype-prism-plus@2.0.1", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q=="],
|
||||
|
||||
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
|
||||
|
||||
"rehype-rewrite": ["rehype-rewrite@4.0.4", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0" } }, "sha512-L/FO96EOzSA6bzOam4DVu61/PB3AGKcSPXpa53yMIozoxH4qg1+bVZDF8zh1EsuxtSauAhzt5cCnvoplAaSLrw=="],
|
||||
|
||||
"rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="],
|
||||
|
||||
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-github-blockquote-alert": ["remark-github-blockquote-alert@1.3.1", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
||||
@@ -849,8 +737,6 @@
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-filter": ["unist-util-filter@5.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
@@ -867,14 +753,10 @@
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
@@ -899,32 +781,8 @@
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@uiw/react-markdown-preview/react-markdown": ["react-markdown@9.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="],
|
||||
|
||||
"@uiw/react-markdown-preview/rehype-prism-plus": ["rehype-prism-plus@2.0.0", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="],
|
||||
|
||||
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"hast-util-parse-selector/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||
|
||||
"hast-util-parse-selector/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"refractor/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ export default tseslint.config([
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"antd": "6",
|
||||
"artplayer": "^5.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
28
web/plugin-frame.html
Normal file
28
web/plugin-frame.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel Plugin Frame</title>
|
||||
<link rel='stylesheet' href='https://foxel.cc/fonts/result.css' />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Maple Mono Normal NL NF CN';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/plugin-frame.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -28,7 +28,67 @@ export interface RepoQueryParams {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// foxel-core 应用中心的数据结构
|
||||
export interface FoxelCoreApp {
|
||||
key: string;
|
||||
version: string;
|
||||
name: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
description: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
author: string;
|
||||
website: string;
|
||||
tags: {
|
||||
zh: string[];
|
||||
en: string[];
|
||||
};
|
||||
approvedAt: number;
|
||||
detailUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppsResponse {
|
||||
apps: FoxelCoreApp[];
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppVersion {
|
||||
version: string;
|
||||
name: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
description: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
author: string;
|
||||
website: string;
|
||||
tags: {
|
||||
zh: string[];
|
||||
en: string[];
|
||||
};
|
||||
approvedAt: number;
|
||||
releaseNotesMd: string | null;
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppDetail {
|
||||
key: string;
|
||||
latest: FoxelCoreAppVersion & {
|
||||
downloadUrl: string;
|
||||
};
|
||||
versions: FoxelCoreAppVersion[];
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppDetailResponse {
|
||||
app: FoxelCoreAppDetail;
|
||||
}
|
||||
|
||||
const CENTER_BASE = 'https://center.foxel.cc';
|
||||
const FOXEL_CORE_BASE = 'https://foxel.cc';
|
||||
|
||||
export function buildCenterUrl(path: string) {
|
||||
return new URL(path, CENTER_BASE).href;
|
||||
@@ -50,3 +110,42 @@ export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoL
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 应用中心获取应用列表
|
||||
*/
|
||||
export async function fetchFoxelCoreApps(): Promise<FoxelCoreApp[]> {
|
||||
const url = `${FOXEL_CORE_BASE}/api/apps`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch apps: ${resp.status}`);
|
||||
}
|
||||
const data: FoxelCoreAppsResponse = await resp.json();
|
||||
return data.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 应用中心获取应用详情(含历史版本)
|
||||
*/
|
||||
export async function fetchFoxelCoreAppDetail(appKey: string): Promise<FoxelCoreAppDetail> {
|
||||
const url = `${FOXEL_CORE_BASE}/api/apps/${encodeURIComponent(appKey)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch app detail: ${resp.status}`);
|
||||
}
|
||||
const data: FoxelCoreAppDetailResponse = await resp.json();
|
||||
return data.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 下载应用包文件
|
||||
*/
|
||||
export async function downloadFoxelCoreApp(app: Pick<FoxelCoreApp, 'key' | 'version' | 'downloadUrl'>): Promise<File> {
|
||||
const url = `${FOXEL_CORE_BASE}${app.downloadUrl}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to download app: ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = `${app.key}-${app.version}.foxpkg`;
|
||||
return new File([blob], filename, { type: 'application/octet-stream' });
|
||||
}
|
||||
|
||||
@@ -2,46 +2,67 @@ import request from './client';
|
||||
|
||||
export interface PluginItem {
|
||||
id: number;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
key: string;
|
||||
open_app?: boolean | null;
|
||||
key?: string | null;
|
||||
name?: string | null;
|
||||
version?: string | null;
|
||||
supported_exts?: string[] | null;
|
||||
default_bounds?: Record<string, any> | null;
|
||||
default_bounds?: Record<string, number> | null;
|
||||
default_maximized?: boolean | null;
|
||||
icon?: string | null;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
website?: string | null;
|
||||
github?: string | null;
|
||||
license?: string | null;
|
||||
manifest?: Record<string, unknown> | null;
|
||||
loaded_routes?: string[] | null;
|
||||
loaded_processors?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PluginCreate {
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginManifestUpdate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
open_app?: boolean;
|
||||
supported_exts?: string[];
|
||||
default_bounds?: Record<string, any>;
|
||||
default_maximized?: boolean;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
export interface PluginInstallResult {
|
||||
success: boolean;
|
||||
plugin?: PluginItem;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
/**
|
||||
* 获取已安装插件列表
|
||||
*/
|
||||
list: () => request<PluginItem[]>(`/plugins`),
|
||||
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
|
||||
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
|
||||
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
|
||||
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
|
||||
|
||||
/**
|
||||
* 获取单个插件详情
|
||||
*/
|
||||
get: (key: string) => request<PluginItem>(`/plugins/${key}`),
|
||||
|
||||
/**
|
||||
* 安装插件(上传 .foxpkg)
|
||||
*/
|
||||
install: async (file: File): Promise<PluginInstallResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request<PluginInstallResult>(`/plugins/install`, {
|
||||
method: 'POST',
|
||||
formData,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除/卸载插件
|
||||
*/
|
||||
remove: (key: string) => request(`/plugins/${key}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* 获取插件 bundle URL
|
||||
*/
|
||||
getBundleUrl: (key: string) => `/api/plugins/${key}/bundle.js`,
|
||||
|
||||
/**
|
||||
* 获取插件资源 URL
|
||||
*/
|
||||
getAssetUrl: (key: string, assetPath: string) =>
|
||||
`/api/plugins/${key}/assets/${assetPath}`,
|
||||
};
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import request from './client';
|
||||
|
||||
export type VideoLibraryMediaType = 'tv' | 'movie';
|
||||
|
||||
export interface VideoLibraryItem {
|
||||
id: string;
|
||||
type: VideoLibraryMediaType;
|
||||
title: string;
|
||||
year?: string | null;
|
||||
overview?: string | null;
|
||||
poster_path?: string | null;
|
||||
backdrop_path?: string | null;
|
||||
genres?: string[];
|
||||
tmdb_id?: number | null;
|
||||
source_path?: string | null;
|
||||
scraped_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
episodes_count?: number;
|
||||
seasons_count?: number;
|
||||
vote_average?: number | null;
|
||||
vote_count?: number | null;
|
||||
}
|
||||
|
||||
export const videoLibraryApi = {
|
||||
list: (params?: { q?: string; type?: VideoLibraryMediaType }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set('q', params.q);
|
||||
if (params?.type) search.set('type', params.type);
|
||||
const suffix = search.toString();
|
||||
return request<VideoLibraryItem[]>(`/plugins/video-player/library${suffix ? `?${suffix}` : ''}`, { method: 'GET' });
|
||||
},
|
||||
get: (id: string) =>
|
||||
request<any>(`/plugins/video-player/library/${encodeURIComponent(id)}`, { method: 'GET' }),
|
||||
};
|
||||
|
||||
@@ -212,6 +212,7 @@ export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClo
|
||||
<div
|
||||
key={w.id}
|
||||
ref={el => { windowEls.current[w.id] = el; }}
|
||||
onMouseDown={() => onBringToFront(w.id)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: w.maximized ? 0 : w.y,
|
||||
|
||||
@@ -1,654 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
FileOutlined,
|
||||
DatabaseOutlined,
|
||||
ExpandOutlined,
|
||||
BgColorsOutlined,
|
||||
ClockCircleOutlined,
|
||||
FolderOutlined,
|
||||
AimOutlined,
|
||||
BulbOutlined,
|
||||
ThunderboltOutlined,
|
||||
AlertOutlined,
|
||||
CameraOutlined,
|
||||
ApiOutlined,
|
||||
FieldTimeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { API_BASE_URL, vfsApi, type VfsEntry } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { ImageCanvas } from './components/ImageCanvas';
|
||||
import { ViewerControls } from './components/ViewerControls';
|
||||
import { Filmstrip } from './components/Filmstrip';
|
||||
import { InfoPanel } from './components/InfoPanel';
|
||||
import type { HistogramData, RgbColor, InfoItem } from './components/types';
|
||||
import { viewerStyles } from './styles';
|
||||
|
||||
interface ExplorerSnapshot {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: { page: number; page_size: number; total: number };
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FileStat {
|
||||
name?: string;
|
||||
is_dir?: boolean;
|
||||
size?: number;
|
||||
mtime?: number;
|
||||
mode?: number;
|
||||
path?: string;
|
||||
type?: string;
|
||||
exif?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'foxel:file-explorer-page': CustomEvent<ExplorerSnapshot>;
|
||||
}
|
||||
}
|
||||
type ExplorerAwareWindow = Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
|
||||
|
||||
const DEFAULT_TONE: RgbColor = { r: 28, g: 32, b: 46 };
|
||||
|
||||
const isImageEntry = (ent: VfsEntry) => {
|
||||
if (ent.is_dir) return false;
|
||||
const maybe = ent as VfsEntry & { has_thumbnail?: boolean };
|
||||
if (typeof maybe.has_thumbnail === 'boolean' && maybe.has_thumbnail) return true;
|
||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return false;
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'ico', 'tif', 'tiff', 'svg', 'heic', 'heif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
|
||||
};
|
||||
|
||||
const buildThumbUrl = (fullPath: string, w = 180, h = 120) => {
|
||||
const base = API_BASE_URL.replace(/\/+$/, '');
|
||||
const clean = fullPath.replace(/^\/+/, '');
|
||||
return `${base}/fs/thumb/${encodeURI(clean)}?w=${w}&h=${h}&fit=cover`;
|
||||
};
|
||||
|
||||
const getDirectory = (fullPath: string) => {
|
||||
const path = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
|
||||
const idx = path.lastIndexOf('/');
|
||||
if (idx <= 0) return '/';
|
||||
return path.slice(0, idx) || '/';
|
||||
};
|
||||
|
||||
const joinPath = (dir: string, name: string) => {
|
||||
if (dir === '/' || dir === '') return `/${name}`;
|
||||
return `${dir.replace(/\/$/, '')}/${name}`;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const parseNumberish = (raw: unknown): number | null => {
|
||||
if (typeof raw === 'number') return raw;
|
||||
if (typeof raw !== 'string') return null;
|
||||
if (raw.includes('/')) {
|
||||
const [a, b] = raw.split('/').map(v => Number(v));
|
||||
if (!Number.isNaN(a) && !Number.isNaN(b) && b !== 0) return a / b;
|
||||
}
|
||||
const val = Number(raw);
|
||||
return Number.isNaN(val) ? null : val;
|
||||
};
|
||||
|
||||
const humanFileSize = (size: number | undefined) => {
|
||||
if (typeof size !== 'number') return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = size;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const readExplorerSnapshot = (dir: string): ExplorerSnapshot | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const snap = (window as ExplorerAwareWindow).__FOXEL_LAST_EXPLORER_PAGE__;
|
||||
if (!snap) return null;
|
||||
const snapshotPath = snap.path === '' ? '/' : snap.path;
|
||||
const normalizedSnap = snapshotPath.endsWith('/') && snapshotPath !== '/' ? snapshotPath.slice(0, -1) : snapshotPath;
|
||||
const normalizedTarget = dir.endsWith('/') && dir !== '/' ? dir.slice(0, -1) : dir;
|
||||
if (normalizedSnap !== normalizedTarget) return null;
|
||||
return snap;
|
||||
};
|
||||
|
||||
const formatDateTime = (ts?: number) => {
|
||||
if (!ts) return '-';
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const clampChannel = (value: number) => Math.max(0, Math.min(255, value));
|
||||
|
||||
const mixColor = (base: RgbColor, target: RgbColor, ratio: number): RgbColor => ({
|
||||
r: clampChannel(base.r * (1 - ratio) + target.r * ratio),
|
||||
g: clampChannel(base.g * (1 - ratio) + target.g * ratio),
|
||||
b: clampChannel(base.b * (1 - ratio) + target.b * ratio),
|
||||
});
|
||||
|
||||
const rgbToRgba = (color: RgbColor, alpha: number) => `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${alpha})`;
|
||||
|
||||
const computeImageStats = (img: HTMLImageElement): { histogram: HistogramData | null; dominantColor: RgbColor | null } => {
|
||||
try {
|
||||
const maxSide = 720;
|
||||
const naturalWidth = img.naturalWidth || 1;
|
||||
const naturalHeight = img.naturalHeight || 1;
|
||||
const ratio = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
|
||||
const width = Math.max(1, Math.floor(naturalWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(naturalHeight * ratio));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return { histogram: null, dominantColor: null };
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
const { data } = ctx.getImageData(0, 0, width, height);
|
||||
const r = new Array(256).fill(0);
|
||||
const g = new Array(256).fill(0);
|
||||
const b = new Array(256).fill(0);
|
||||
let rTotal = 0;
|
||||
let gTotal = 0;
|
||||
let bTotal = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
r[data[i]] += 1;
|
||||
g[data[i + 1]] += 1;
|
||||
b[data[i + 2]] += 1;
|
||||
rTotal += data[i];
|
||||
gTotal += data[i + 1];
|
||||
bTotal += data[i + 2];
|
||||
count += 1;
|
||||
}
|
||||
const histogram: HistogramData = { r, g, b };
|
||||
if (count === 0) return { histogram, dominantColor: null };
|
||||
const dominantColor: RgbColor = {
|
||||
r: rTotal / count,
|
||||
g: gTotal / count,
|
||||
b: bTotal / count,
|
||||
};
|
||||
return { histogram, dominantColor };
|
||||
} catch {
|
||||
return { histogram: null, dominantColor: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
const normalizedInitialPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
const [activeEntry, setActiveEntry] = useState<VfsEntry>(entry);
|
||||
const [activePath, setActivePath] = useState<string>(normalizedInitialPath);
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>();
|
||||
const [stat, setStat] = useState<FileStat | null>(null);
|
||||
const [histogram, setHistogram] = useState<HistogramData | null>(null);
|
||||
const [dominantColor, setDominantColor] = useState<RgbColor | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [rotate, setRotate] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [filmstrip, setFilmstrip] = useState<VfsEntry[]>([]);
|
||||
const [pageInfo, setPageInfo] = useState<{ page: number; total: number; pageSize: number } | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const dragPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pinchDistanceRef = useRef<number | null>(null);
|
||||
const transitionRef = useRef(false);
|
||||
const filmstripRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const directory = useMemo(() => getDirectory(activePath), [activePath]);
|
||||
|
||||
const baseTone = useMemo<RgbColor>(() => dominantColor ?? DEFAULT_TONE, [dominantColor]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const light = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.18);
|
||||
const shadow = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.62);
|
||||
return {
|
||||
...viewerStyles.container,
|
||||
background: `linear-gradient(135deg, ${rgbToRgba(light, 0.78)} 0%, ${rgbToRgba(baseTone, 0.86)} 48%, ${rgbToRgba(shadow, 0.96)} 100%)`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const mainBackdropStyle = useMemo(() => {
|
||||
const glow = mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.32);
|
||||
const shade = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.7);
|
||||
return {
|
||||
...viewerStyles.mainBackdrop,
|
||||
background: `radial-gradient(circle at 18% 22%, ${rgbToRgba(glow, 0.38)}, ${rgbToRgba(shade, 0.94)} 68%)`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const viewerStyle = useMemo(() => {
|
||||
const surface = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.45);
|
||||
const edge = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.65);
|
||||
return {
|
||||
...viewerStyles.viewer,
|
||||
background: `linear-gradient(145deg, ${rgbToRgba(surface, 0.7)} 0%, ${rgbToRgba(edge, 0.92)} 100%)`,
|
||||
backdropFilter: 'blur(28px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const controlsStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.52);
|
||||
return {
|
||||
...viewerStyles.controls,
|
||||
background: rgbToRgba(tone, 0.74),
|
||||
backdropFilter: 'blur(18px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const filmstripShellStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.56);
|
||||
return {
|
||||
...viewerStyles.filmstripShell,
|
||||
background: rgbToRgba(tone, 0.7),
|
||||
backdropFilter: 'blur(22px)',
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const getThumbUrl = useCallback((item: VfsEntry) => {
|
||||
const full = joinPath(directory, item.name);
|
||||
return buildThumbUrl(full, 160, 120);
|
||||
}, [directory]);
|
||||
|
||||
const sidePanelStyle = useMemo(() => {
|
||||
const panel = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.6);
|
||||
const border = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.1), 0.28);
|
||||
return {
|
||||
...viewerStyles.sidePanel,
|
||||
background: rgbToRgba(panel, 0.8),
|
||||
backdropFilter: 'blur(28px)',
|
||||
borderLeft: `1px solid ${border}`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
const histogramCardStyle = useMemo(() => {
|
||||
const tone = mixColor(baseTone, { r: 0, g: 0, b: 0 }, 0.55);
|
||||
const stroke = rgbToRgba(mixColor(baseTone, { r: 255, g: 255, b: 255 }, 0.12), 0.2);
|
||||
return {
|
||||
...viewerStyles.histogramCard,
|
||||
background: rgbToRgba(tone, 0.58),
|
||||
border: `1px solid ${stroke}`,
|
||||
};
|
||||
}, [baseTone]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = filePath.startsWith('/') ? filePath : `/${filePath}`;
|
||||
setActiveEntry(entry);
|
||||
setActivePath(normalized);
|
||||
}, [entry, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
setHistogram(null);
|
||||
setDominantColor(null);
|
||||
const cleaned = activePath.replace(/^\/+/, '');
|
||||
Promise.all([
|
||||
vfsApi.getTempLinkToken(cleaned),
|
||||
vfsApi.stat(activePath) as Promise<FileStat>,
|
||||
])
|
||||
.then(([token, metadata]) => {
|
||||
if (cancelled) return;
|
||||
setImageUrl(vfsApi.getTempPublicUrl(token.token));
|
||||
setStat(metadata);
|
||||
setScale(1);
|
||||
setRotate(0);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activePath]);
|
||||
|
||||
const refreshFilmstrip = useCallback((dir: string) => {
|
||||
const snap = readExplorerSnapshot(dir);
|
||||
if (snap) {
|
||||
const images = snap.entries.filter(isImageEntry);
|
||||
const ensured = images.some(item => item.name === activeEntry.name) ? images : [...images, activeEntry];
|
||||
setFilmstrip(ensured);
|
||||
if (snap.pagination) {
|
||||
setPageInfo({
|
||||
page: snap.pagination.page,
|
||||
pageSize: snap.pagination.page_size,
|
||||
total: snap.pagination.total,
|
||||
});
|
||||
} else {
|
||||
setPageInfo(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFilmstrip([activeEntry]);
|
||||
setPageInfo(null);
|
||||
}, [activeEntry]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshFilmstrip(directory);
|
||||
}, [directory, refreshFilmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => refreshFilmstrip(directory);
|
||||
window.addEventListener('foxel:file-explorer-page', handler);
|
||||
return () => window.removeEventListener('foxel:file-explorer-page', handler);
|
||||
}, [directory, refreshFilmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = filmstripRefs.current[activeEntry.name];
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
}, [activeEntry, filmstrip]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
switchRelative(1);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
switchRelative(-1);
|
||||
} else if ((e.key === '+' || e.key === '=') && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
zoom(1.15);
|
||||
} else if ((e.key === '-' || e.key === '_') && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
zoom(0.85);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', keyHandler);
|
||||
return () => window.removeEventListener('keydown', keyHandler);
|
||||
});
|
||||
|
||||
const zoom = useCallback((factor: number) => {
|
||||
setScale(prev => {
|
||||
const next = clamp(prev * factor, 0.08, 10);
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 120);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rotateImage = () => {
|
||||
setRotate(prev => {
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 180);
|
||||
return (prev + 90) % 360;
|
||||
});
|
||||
};
|
||||
|
||||
const resetView = () => {
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 160);
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
};
|
||||
|
||||
const fitToScreen = () => {
|
||||
resetView();
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
setScale(prev => {
|
||||
const factor = e.deltaY < 0 ? 1.12 : 0.88;
|
||||
const next = clamp(prev * factor, 0.08, 10);
|
||||
const ratio = next / prev;
|
||||
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
|
||||
transitionRef.current = true;
|
||||
window.setTimeout(() => { transitionRef.current = false; }, 120);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !dragPointRef.current) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - dragPointRef.current.x;
|
||||
const dy = e.clientY - dragPointRef.current.y;
|
||||
dragPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
setIsDragging(false);
|
||||
dragPointRef.current = null;
|
||||
};
|
||||
|
||||
const dist = (t1: React.Touch, t2: React.Touch) => Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
dragPointRef.current = { x: t.clientX, y: t.clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
pinchDistanceRef.current = dist(e.touches[0], e.touches[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1 && dragPointRef.current) {
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - dragPointRef.current.x;
|
||||
const dy = t.clientY - dragPointRef.current.y;
|
||||
dragPointRef.current = { x: t.clientX, y: t.clientY };
|
||||
setOffset(off => ({ x: off.x + dx, y: off.y + dy }));
|
||||
} else if (e.touches.length === 2 && pinchDistanceRef.current) {
|
||||
const dNow = dist(e.touches[0], e.touches[1]);
|
||||
const ratio = dNow / pinchDistanceRef.current;
|
||||
pinchDistanceRef.current = dNow;
|
||||
setScale(prev => clamp(prev * ratio, 0.08, 10));
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
pinchDistanceRef.current = null;
|
||||
dragPointRef.current = null;
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const next = scale > 1.4 ? 1 : 2.2;
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
setScale(next);
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
const ratio = next / scale;
|
||||
setScale(next);
|
||||
setOffset(off => ({ x: off.x - cx * (ratio - 1), y: off.y - cy * (ratio - 1) }));
|
||||
};
|
||||
|
||||
const handleImageLoaded = () => {
|
||||
const img = imageRef.current;
|
||||
if (!img) return;
|
||||
const stats = computeImageStats(img);
|
||||
setHistogram(stats.histogram);
|
||||
setDominantColor(stats.dominantColor);
|
||||
};
|
||||
|
||||
const switchEntry = (target: VfsEntry) => {
|
||||
const nextPath = joinPath(directory, target.name);
|
||||
setActiveEntry(target);
|
||||
setActivePath(nextPath);
|
||||
};
|
||||
|
||||
const switchRelative = (step: number) => {
|
||||
if (filmstrip.length <= 1) return;
|
||||
const currentIndex = filmstrip.findIndex(item => item.name === activeEntry.name);
|
||||
if (currentIndex === -1) return;
|
||||
const target = filmstrip[(currentIndex + step + filmstrip.length) % filmstrip.length];
|
||||
if (target) switchEntry(target);
|
||||
};
|
||||
|
||||
const scaleLabel = `${(scale * 100).toFixed(scale >= 1 ? 0 : 1)}%`;
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
|
||||
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
|
||||
cursor: isDragging ? 'grabbing' : scale > 1 ? 'grab' : 'zoom-in',
|
||||
willChange: 'transform',
|
||||
};
|
||||
|
||||
const controlsNode = (
|
||||
<ViewerControls
|
||||
style={controlsStyle}
|
||||
onPrev={() => switchRelative(-1)}
|
||||
onNext={() => switchRelative(1)}
|
||||
onZoomIn={() => zoom(1.18)}
|
||||
onZoomOut={() => zoom(0.82)}
|
||||
onRotate={rotateImage}
|
||||
onReset={resetView}
|
||||
onFit={fitToScreen}
|
||||
disableSwitch={filmstrip.length <= 1}
|
||||
/>
|
||||
);
|
||||
|
||||
const exif = (stat?.exif ?? {}) as Record<string, unknown>;
|
||||
const infoIconStyle: React.CSSProperties = { fontSize: 15, color: 'rgba(255,255,255,0.62)' };
|
||||
const exifValue = (key: string): string | number | null => {
|
||||
const value = exif[key];
|
||||
if (typeof value === 'string' || typeof value === 'number') return value;
|
||||
return null;
|
||||
};
|
||||
const focalLength = (() => {
|
||||
const v = parseNumberish(exifValue('37386') ?? exifValue('37377'));
|
||||
return v ? `${v.toFixed(1)} mm` : null;
|
||||
})();
|
||||
const aperture = (() => {
|
||||
const v = parseNumberish(exifValue('33437') ?? exifValue('37378'));
|
||||
return v ? `f/${v.toFixed(1)}` : null;
|
||||
})();
|
||||
const exposure = (() => {
|
||||
const v = parseNumberish(exifValue('33434'));
|
||||
if (!v) return null;
|
||||
if (v >= 1) return `${v.toFixed(1)} s`;
|
||||
const denom = Math.max(1, Math.round(1 / v));
|
||||
return `1/${denom}`;
|
||||
})();
|
||||
const isoValue = exifValue('34855') ?? exifValue('34864');
|
||||
const width = parseNumberish(exifValue('40962'));
|
||||
const height = parseNumberish(exifValue('40963'));
|
||||
const colorSpace = exifValue('40961');
|
||||
const cameraMake = exifValue('271');
|
||||
const cameraModel = exifValue('272');
|
||||
const lensModel = exifValue('42036');
|
||||
const captureTime = exifValue('36867') ?? exifValue('36868') ?? exifValue('306');
|
||||
|
||||
const basicList: InfoItem[] = [
|
||||
{ label: '文件名', value: activeEntry.name, icon: <FileOutlined style={infoIconStyle} /> },
|
||||
{ label: '文件大小', value: humanFileSize(stat?.size), icon: <DatabaseOutlined style={infoIconStyle} /> },
|
||||
{ label: '分辨率', value: width && height ? `${width} × ${height}` : null, icon: <ExpandOutlined style={infoIconStyle} /> },
|
||||
{ label: '颜色空间', value: colorSpace ?? null, icon: <BgColorsOutlined style={infoIconStyle} /> },
|
||||
{ label: '修改时间', value: stat?.mtime ? formatDateTime(stat.mtime) : null, icon: <ClockCircleOutlined style={infoIconStyle} /> },
|
||||
{ label: '路径', value: typeof stat?.path === 'string' ? stat.path : activePath, icon: <FolderOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const shootingList: InfoItem[] = [
|
||||
{ label: '焦距', value: focalLength, icon: <AimOutlined style={infoIconStyle} /> },
|
||||
{ label: '光圈', value: aperture, icon: <BulbOutlined style={infoIconStyle} /> },
|
||||
{ label: '快门', value: exposure, icon: <ThunderboltOutlined style={infoIconStyle} /> },
|
||||
{ label: 'ISO', value: isoValue != null ? isoValue.toString() : null, icon: <AlertOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const deviceList: InfoItem[] = [
|
||||
{
|
||||
label: '相机',
|
||||
value: cameraModel ? `${cameraMake ? `${cameraMake} ` : ''}${cameraModel}` : (cameraMake ?? null),
|
||||
icon: <CameraOutlined style={infoIconStyle} />,
|
||||
},
|
||||
{ label: '镜头', value: lensModel ?? null, icon: <ApiOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
const miscList: InfoItem[] = [
|
||||
{ label: '拍摄时间', value: captureTime, icon: <FieldTimeOutlined style={infoIconStyle} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<section style={viewerStyles.main}>
|
||||
<div style={mainBackdropStyle} />
|
||||
<div style={viewerStyles.mainContent}>
|
||||
<ImageCanvas
|
||||
containerRef={containerRef}
|
||||
imageRef={imageRef}
|
||||
viewerStyle={viewerStyle}
|
||||
controls={controlsNode}
|
||||
scaleLabel={scaleLabel}
|
||||
imageStyle={imageStyle}
|
||||
loading={loading}
|
||||
error={error}
|
||||
imageUrl={imageUrl}
|
||||
activeEntry={activeEntry}
|
||||
onRequestClose={onRequestClose}
|
||||
onImageLoad={handleImageLoaded}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={stopDragging}
|
||||
onMouseUp={stopDragging}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
|
||||
<Filmstrip
|
||||
shellStyle={filmstripShellStyle}
|
||||
listStyle={viewerStyles.filmstrip}
|
||||
entries={filmstrip}
|
||||
activeEntry={activeEntry}
|
||||
onSelect={switchEntry}
|
||||
filmstripRefs={filmstripRefs}
|
||||
pageInfo={pageInfo}
|
||||
getThumbUrl={getThumbUrl}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<InfoPanel
|
||||
style={sidePanelStyle}
|
||||
histogramCardStyle={histogramCardStyle}
|
||||
title={activeEntry.name}
|
||||
captureTime={captureTime ?? null}
|
||||
basicList={basicList}
|
||||
shootingList={shootingList}
|
||||
deviceList={deviceList}
|
||||
miscList={miscList}
|
||||
histogram={histogram}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface PageInfo {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface FilmstripProps {
|
||||
shellStyle: React.CSSProperties;
|
||||
listStyle: React.CSSProperties;
|
||||
entries: VfsEntry[];
|
||||
activeEntry: VfsEntry;
|
||||
onSelect: (entry: VfsEntry) => void;
|
||||
filmstripRefs: React.MutableRefObject<Record<string, HTMLDivElement | null>>;
|
||||
pageInfo: PageInfo | null;
|
||||
getThumbUrl: (entry: VfsEntry) => string;
|
||||
}
|
||||
|
||||
export const Filmstrip: React.FC<FilmstripProps> = ({
|
||||
shellStyle,
|
||||
listStyle,
|
||||
entries,
|
||||
activeEntry,
|
||||
onSelect,
|
||||
filmstripRefs,
|
||||
pageInfo,
|
||||
getThumbUrl,
|
||||
}) => (
|
||||
<div style={shellStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.72)', fontWeight: 500 }}>
|
||||
胶片带 · {entries.length} 张
|
||||
</Typography.Text>
|
||||
{pageInfo && (
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>
|
||||
第 {pageInfo.page} 页 / 共 {Math.max(1, Math.ceil(pageInfo.total / pageInfo.pageSize))} 页
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={listStyle}>
|
||||
{entries.map(item => {
|
||||
const active = item.name === activeEntry.name;
|
||||
return (
|
||||
<div
|
||||
key={`${item.name}-${item.mtime ?? ''}`}
|
||||
ref={el => { filmstripRefs.current[item.name] = el; }}
|
||||
onClick={() => onSelect(item)}
|
||||
style={{
|
||||
width: 84,
|
||||
height: 64,
|
||||
overflow: 'hidden',
|
||||
border: active ? '2px solid #4e9bff' : '2px solid transparent',
|
||||
boxShadow: active ? '0 0 0 4px rgba(78,155,255,0.28)' : '0 10px 28px rgba(0,0,0,0.45)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
flex: '0 0 auto',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getThumbUrl(item)}
|
||||
alt={item.name}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', filter: active ? 'saturate(1)' : 'saturate(0.65)' }}
|
||||
/>
|
||||
{active && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 6,
|
||||
right: 6,
|
||||
padding: '2px 4px',
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && (
|
||||
<div style={{ color: 'rgba(255,255,255,0.45)' }}>暂无图片</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Spin, Typography, Tooltip, Button } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { viewerStyles } from '../styles';
|
||||
|
||||
interface ImageCanvasProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
imageRef: React.RefObject<HTMLImageElement | null>;
|
||||
viewerStyle: React.CSSProperties;
|
||||
controls: React.ReactNode;
|
||||
scaleLabel: string;
|
||||
imageStyle: React.CSSProperties;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
imageUrl?: string;
|
||||
activeEntry: VfsEntry;
|
||||
onRequestClose: () => void;
|
||||
onImageLoad: () => void;
|
||||
onWheel: React.WheelEventHandler<HTMLDivElement>;
|
||||
onMouseDown: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseMove: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseLeave: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseUp: React.MouseEventHandler<HTMLDivElement>;
|
||||
onDoubleClick: React.MouseEventHandler<HTMLDivElement>;
|
||||
onTouchStart: React.TouchEventHandler<HTMLDivElement>;
|
||||
onTouchMove: React.TouchEventHandler<HTMLDivElement>;
|
||||
onTouchEnd: React.TouchEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ImageCanvas: React.FC<ImageCanvasProps> = ({
|
||||
containerRef,
|
||||
imageRef,
|
||||
viewerStyle,
|
||||
controls,
|
||||
scaleLabel,
|
||||
imageStyle,
|
||||
loading,
|
||||
error,
|
||||
imageUrl,
|
||||
activeEntry,
|
||||
onRequestClose,
|
||||
onImageLoad,
|
||||
onWheel,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onMouseUp,
|
||||
onDoubleClick,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
}) => (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={viewerStyle}
|
||||
onWheel={onWheel}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
<div style={viewerStyles.viewerCloseWrap}>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onRequestClose}
|
||||
style={viewerStyles.viewerClose}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Spin tip="加载中" />
|
||||
) : error ? (
|
||||
<Typography.Text type="danger">{error}</Typography.Text>
|
||||
) : imageUrl ? (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={imageUrl}
|
||||
alt={activeEntry.name}
|
||||
onLoad={onImageLoad}
|
||||
draggable={false}
|
||||
crossOrigin="anonymous"
|
||||
style={imageStyle}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text>无可用内容</Typography.Text>
|
||||
)}
|
||||
|
||||
<div style={viewerStyles.scaleBadge}>{scaleLabel}</div>
|
||||
|
||||
{controls}
|
||||
</div>
|
||||
);
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Typography, Empty } from 'antd';
|
||||
import type { HistogramData, InfoItem } from './types';
|
||||
|
||||
interface InfoPanelProps {
|
||||
style: React.CSSProperties;
|
||||
histogramCardStyle: React.CSSProperties;
|
||||
title: string;
|
||||
captureTime: string | number | null;
|
||||
basicList: InfoItem[];
|
||||
shootingList: InfoItem[];
|
||||
deviceList: InfoItem[];
|
||||
miscList: InfoItem[];
|
||||
histogram: HistogramData | null;
|
||||
}
|
||||
|
||||
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Typography.Title level={5} style={{ color: '#fff', fontSize: 15, marginTop: 24, marginBottom: 12 }}>
|
||||
{children}
|
||||
</Typography.Title>
|
||||
);
|
||||
|
||||
const HistogramPlot: React.FC<{ data: HistogramData | null }> = ({ data }) => {
|
||||
if (!data) {
|
||||
return <Empty description="无法解析直方图" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
}
|
||||
const width = 260;
|
||||
const height = 140;
|
||||
const max = Math.max(...data.r, ...data.g, ...data.b, 1);
|
||||
const toPath = (arr: number[]) => arr
|
||||
.map((value, index) => {
|
||||
const x = (index / 255) * width;
|
||||
const y = height - (value / max) * height;
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
})
|
||||
.join(' ');
|
||||
return (
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ width: '100%' }}>
|
||||
<rect x={0} y={0} width={width} height={height} fill="rgba(255,255,255,0.04)" />
|
||||
<path d={toPath(data.r)} stroke="rgba(255,99,132,0.88)" fill="none" strokeWidth={1.3} />
|
||||
<path d={toPath(data.g)} stroke="rgba(75,192,192,0.88)" fill="none" strokeWidth={1.3} />
|
||||
<path d={toPath(data.b)} stroke="rgba(54,162,235,0.88)" fill="none" strokeWidth={1.3} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoRows: React.FC<{ items: InfoItem[] }> = ({ items }) => (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '100px 1fr', rowGap: 10, columnGap: 12 }}>
|
||||
{items
|
||||
.filter(item => item.value !== null && item.value !== undefined && item.value !== '')
|
||||
.map(item => (
|
||||
<React.Fragment key={item.label}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.55)' }}>
|
||||
{item.icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
<span style={{ color: '#fff', wordBreak: 'break-all' }}>{item.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InfoPanel: React.FC<InfoPanelProps> = ({
|
||||
style,
|
||||
histogramCardStyle,
|
||||
title,
|
||||
captureTime,
|
||||
basicList,
|
||||
shootingList,
|
||||
deviceList,
|
||||
miscList,
|
||||
histogram,
|
||||
}) => (
|
||||
<aside style={style}>
|
||||
<Typography.Title level={3} style={{ color: '#fff', marginTop: 6, wordBreak: 'break-all' }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{captureTime && (
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.6)' }}>拍摄时间 {captureTime}</Typography.Text>
|
||||
)}
|
||||
|
||||
<SectionTitle>基本信息</SectionTitle>
|
||||
<InfoRows items={basicList} />
|
||||
|
||||
{shootingList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>拍摄参数</SectionTitle>
|
||||
<InfoRows items={shootingList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{deviceList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>设备信息</SectionTitle>
|
||||
<InfoRows items={deviceList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{miscList.some(i => i.value) && (
|
||||
<>
|
||||
<SectionTitle>其他</SectionTitle>
|
||||
<InfoRows items={miscList} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionTitle>直方图</SectionTitle>
|
||||
<div style={histogramCardStyle}>
|
||||
<HistogramPlot data={histogram} />
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 12, fontSize: 12 }}>
|
||||
<span style={{ color: 'rgba(255,99,132,0.88)' }}>R</span>
|
||||
<span style={{ color: 'rgba(75,192,192,0.88)' }}>G</span>
|
||||
<span style={{ color: 'rgba(54,162,235,0.88)' }}>B</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
RotateRightOutlined,
|
||||
ReloadOutlined,
|
||||
CompressOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ViewerControlsProps {
|
||||
style: React.CSSProperties;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onRotate: () => void;
|
||||
onReset: () => void;
|
||||
onFit: () => void;
|
||||
disableSwitch: boolean;
|
||||
}
|
||||
|
||||
export const ViewerControls: React.FC<ViewerControlsProps> = ({
|
||||
style,
|
||||
onPrev,
|
||||
onNext,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onRotate,
|
||||
onReset,
|
||||
onFit,
|
||||
disableSwitch,
|
||||
}) => (
|
||||
<div style={style}>
|
||||
<Tooltip title="上一张">
|
||||
<Button
|
||||
shape="circle"
|
||||
type="text"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={onPrev}
|
||||
disabled={disableSwitch}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Button shape="circle" type="text" icon={<ZoomOutOutlined />} onClick={onZoomOut} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="放大">
|
||||
<Button shape="circle" type="text" icon={<ZoomInOutlined />} onClick={onZoomIn} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="旋转 90°">
|
||||
<Button shape="circle" type="text" icon={<RotateRightOutlined />} onClick={onRotate} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="重置">
|
||||
<Button shape="circle" type="text" icon={<ReloadOutlined />} onClick={onReset} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="适应窗口">
|
||||
<Button shape="circle" type="text" icon={<CompressOutlined />} onClick={onFit} style={{ color: '#fff' }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="下一张">
|
||||
<Button
|
||||
shape="circle"
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={onNext}
|
||||
disabled={disableSwitch}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface HistogramData {
|
||||
r: number[];
|
||||
g: number[];
|
||||
b: number[];
|
||||
}
|
||||
|
||||
export interface RgbColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface InfoItem {
|
||||
label: string;
|
||||
value: string | number | null;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { ImageViewerApp } from './ImageViewer.tsx';
|
||||
|
||||
const supportedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'image-viewer',
|
||||
name: '图片查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:image.svg',
|
||||
description: '内置图片查看器,支持常见图片与部分 RAW 格式预览。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: ImageViewerApp,
|
||||
default: true,
|
||||
defaultMaximized:true,
|
||||
useSystemWindow:false,
|
||||
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
|
||||
};
|
||||
@@ -1,106 +0,0 @@
|
||||
export const viewerStyles = {
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 320px',
|
||||
columnGap: 0,
|
||||
color: '#fff',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
main: {
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
|
||||
minHeight: 0,
|
||||
},
|
||||
mainBackdrop: {
|
||||
position: 'absolute' as const,
|
||||
inset: 0,
|
||||
},
|
||||
mainContent: {
|
||||
position: 'relative' as const,
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
viewer: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||
touchAction: 'none' as const,
|
||||
minHeight: 0,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute' as const,
|
||||
bottom: 16,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
padding: '8px 18px',
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
scaleBadge: {
|
||||
position: 'absolute' as const,
|
||||
bottom: 64,
|
||||
left: 16,
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
filmstripShell: {
|
||||
marginTop: 0,
|
||||
padding: '3px 12px',
|
||||
boxShadow: '0 16px 42px rgba(0,0,0,0.52)',
|
||||
},
|
||||
filmstrip: {
|
||||
display: 'flex',
|
||||
overflowX: 'auto' as const,
|
||||
gap: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
sidePanel: {
|
||||
boxShadow: '0 28px 80px rgba(0,0,0,0.55)',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
overflowY: 'auto' as const,
|
||||
minHeight: 0,
|
||||
},
|
||||
histogramCard: {
|
||||
padding: '12px 12px 18px',
|
||||
background: 'rgba(0,0,0,0.34)',
|
||||
borderRadius: 0,
|
||||
},
|
||||
viewerCloseWrap: {
|
||||
position: 'absolute' as const,
|
||||
top: 16,
|
||||
right: 16,
|
||||
zIndex: 2,
|
||||
},
|
||||
viewerClose: {
|
||||
color: '#fff',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
boxShadow: '0 8px 18px rgba(0,0,0,0.45)',
|
||||
borderRadius: '100%',
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { useSystemStatus } from '../../contexts/SystemContext';
|
||||
|
||||
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
||||
const systemStatus = useSystemStatus();
|
||||
const fileDomain = systemStatus?.file_domain;
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setErr(undefined);
|
||||
setUrl(undefined);
|
||||
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
const baseUrl = fileDomain || window.location.origin;
|
||||
const fullUrl = new URL(res.url, baseUrl).href;
|
||||
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||
setUrl(officeUrl);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) {
|
||||
setErr(e.message || '加载文档链接失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filePath, fileDomain]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="正在准备文档..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="无法加载文档"
|
||||
subTitle={err}
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{url ? (
|
||||
<iframe
|
||||
src={url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
title="Office Document Viewer"
|
||||
/>
|
||||
) : (
|
||||
<Result
|
||||
status="warning"
|
||||
title="文档链接无效"
|
||||
subTitle="未能成功生成文档的在线查看链接。"
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { OfficeViewerApp } from './OfficeViewer.tsx';
|
||||
|
||||
const supportedExts = ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'office-viewer',
|
||||
name: 'Office 文档查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
|
||||
description: '内置 Office 文档查看器,支持 Word/Excel/PowerPoint 文件预览。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: OfficeViewerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/client';
|
||||
|
||||
export const PdfViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setErr(undefined);
|
||||
setUrl(undefined);
|
||||
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
const publicUrl = vfsApi.getTempPublicUrl(res.token);
|
||||
setUrl(publicUrl + '#toolbar=1&navpanes=1');
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) setErr(e.message || '获取临时链接失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [filePath]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="正在加载 PDF..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="无法加载 PDF"
|
||||
subTitle={err}
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<Result
|
||||
status="warning"
|
||||
title="无可用链接"
|
||||
subTitle="未能生成 PDF 的临时访问链接"
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
<iframe
|
||||
src={url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title="PDF Viewer"
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { PdfViewerApp } from './PdfViewer';
|
||||
|
||||
const supportedExts = ['pdf'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'pdf-viewer',
|
||||
name: 'PDF 查看器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
|
||||
description: '内置 PDF 查看器。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: PdfViewerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 160, y: 100 },
|
||||
};
|
||||
@@ -1,108 +1,109 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import type { AppComponentProps, AppOpenComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import { loadPlugin, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
export interface PluginAppHostProps extends AppComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
function buildPluginFrameUrl(params: Record<string, string>): string {
|
||||
const qs = new URLSearchParams(params);
|
||||
return `/plugin-frame.html?${qs.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 文件打开模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
|
||||
*/
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||
plugin,
|
||||
filePath,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
const { t } = useI18n();
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPlugin(plugin);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
if (isDisposed()) return;
|
||||
const token = await vfsApi.getTempLinkToken(filePath);
|
||||
if (isDisposed()) return;
|
||||
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
|
||||
if (isDisposed() || !containerRef.current) return;
|
||||
await p.mount(containerRef.current, {
|
||||
const src = useMemo(
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
mode: 'file',
|
||||
filePath,
|
||||
entry,
|
||||
urls: { downloadUrl },
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url, filePath],
|
||||
() => {
|
||||
try {
|
||||
if (pluginRef.current?.unmount && containerRef.current) {
|
||||
pluginRef.current.unmount(containerRef.current);
|
||||
}
|
||||
} catch { void 0; }
|
||||
},
|
||||
}),
|
||||
[plugin.key, filePath]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = ev.data as any;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`plugin:${plugin.key}`}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PluginAppOpenHostProps extends AppOpenComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 独立应用模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
|
||||
*/
|
||||
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
const { t } = useI18n();
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPlugin(plugin);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
if (isDisposed() || !containerRef.current) return;
|
||||
if (typeof p.mountApp !== 'function') {
|
||||
throw new Error('该插件不支持独立打开');
|
||||
}
|
||||
await p.mountApp(containerRef.current, {
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || t('Plugin run failed'));
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url],
|
||||
() => {
|
||||
try {
|
||||
if (!containerRef.current) return;
|
||||
const p = pluginRef.current;
|
||||
if (p?.unmountApp) return p.unmountApp(containerRef.current);
|
||||
if (p?.unmount) return p.unmount(containerRef.current);
|
||||
} catch { void 0; }
|
||||
},
|
||||
const src = useMemo(
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
mode: 'app',
|
||||
}),
|
||||
[plugin.key]
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>{t('Plugin Error')}: {error}</div>;
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = ev.data as any;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`plugin:${plugin.key}:app`}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import { Layout, Spin, Button, Space, message } from 'antd';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import request from '../../api/client';
|
||||
|
||||
const MonacoEditor = React.lazy(() => import('@monaco-editor/react'));
|
||||
const MarkdownEditor = React.lazy(() => import('@uiw/react-md-editor'));
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
const MAX_PREVIEW_BYTES = 1024 * 1024; // 1MB
|
||||
|
||||
export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
const [initialContent, setInitialContent] = useState('');
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const isDirty = content !== initialContent;
|
||||
const onRequestCloseRef = useRef(onRequestClose);
|
||||
onRequestCloseRef.current = onRequestClose;
|
||||
|
||||
const ext = useMemo(() => entry.name.split('.').pop()?.toLowerCase() || '', [entry.name]);
|
||||
const isMarkdown = ext === 'md' || ext === 'markdown';
|
||||
const monacoLanguage = useMemo(() => {
|
||||
switch (ext) {
|
||||
// Web technologies
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return 'javascript';
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'html';
|
||||
case 'css':
|
||||
return 'css';
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
return 'scss';
|
||||
case 'less':
|
||||
return 'less';
|
||||
case 'vue':
|
||||
return 'html'; // Vue files are primarily HTML with some JS/TS
|
||||
|
||||
// Data formats
|
||||
case 'json':
|
||||
return 'json';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'yaml';
|
||||
case 'xml':
|
||||
return 'xml';
|
||||
case 'toml':
|
||||
return 'ini'; // TOML is similar to INI
|
||||
case 'ini':
|
||||
case 'cfg':
|
||||
case 'conf':
|
||||
return 'ini';
|
||||
|
||||
// Programming languages
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'c':
|
||||
return 'c';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
return 'cpp';
|
||||
case 'h':
|
||||
case 'hpp':
|
||||
case 'hxx':
|
||||
return 'cpp'; // Header files use C++ highlighting
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'rb':
|
||||
return 'ruby';
|
||||
case 'go':
|
||||
return 'go';
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'kt':
|
||||
return 'kotlin';
|
||||
case 'scala':
|
||||
return 'scala';
|
||||
case 'cs':
|
||||
return 'csharp';
|
||||
case 'fs':
|
||||
return 'fsharp';
|
||||
case 'vb':
|
||||
return 'vb';
|
||||
case 'pl':
|
||||
case 'pm':
|
||||
return 'perl';
|
||||
case 'r':
|
||||
return 'r';
|
||||
case 'lua':
|
||||
return 'lua';
|
||||
case 'dart':
|
||||
return 'dart';
|
||||
|
||||
// Database
|
||||
case 'sql':
|
||||
return 'sql';
|
||||
|
||||
// Shell and scripts
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
return 'shell';
|
||||
case 'ps1':
|
||||
return 'powershell';
|
||||
case 'bat':
|
||||
case 'cmd':
|
||||
return 'bat';
|
||||
|
||||
// Build and config files
|
||||
case 'dockerfile':
|
||||
return 'dockerfile';
|
||||
case 'makefile':
|
||||
return 'makefile';
|
||||
case 'gradle':
|
||||
return 'groovy';
|
||||
case 'cmake':
|
||||
return 'cmake';
|
||||
|
||||
// Markdown
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return 'markdown';
|
||||
|
||||
// Plain text and logs
|
||||
case 'txt':
|
||||
case 'log':
|
||||
case 'gitignore':
|
||||
case 'gitattributes':
|
||||
case 'editorconfig':
|
||||
case 'prettierrc':
|
||||
default:
|
||||
return 'plaintext';
|
||||
}
|
||||
}, [ext]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setTruncated(false);
|
||||
const shouldTruncate = (entry.size ?? 0) > MAX_PREVIEW_BYTES;
|
||||
if (shouldTruncate) {
|
||||
const enc = encodeURI(filePath.replace(/^\/+/, ''));
|
||||
const resp = await request(`/fs/file/${enc}`, {
|
||||
method: 'GET',
|
||||
headers: { Range: `bytes=0-${MAX_PREVIEW_BYTES - 1}` },
|
||||
rawResponse: true,
|
||||
});
|
||||
const buf = await (resp as Response).arrayBuffer();
|
||||
const text = new TextDecoder().decode(buf);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
setTruncated(true);
|
||||
} else {
|
||||
const data = await vfsApi.readFile(filePath);
|
||||
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
onRequestCloseRef.current();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadFile();
|
||||
}, [filePath, entry.size]);
|
||||
const handleSave = useCallback(async () => {
|
||||
if (truncated) {
|
||||
message.warning('大文件仅预览前 1MB,已禁用保存');
|
||||
return;
|
||||
}
|
||||
if (!isDirty) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
await vfsApi.uploadFile(filePath, blob);
|
||||
setInitialContent(content);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
message.error(`保存文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, isDirty, truncated]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: 'var(--ant-color-bg-container, #ffffff)' }}>
|
||||
<Header
|
||||
style={{
|
||||
background: 'var(--ant-color-bg-layout, #f0f2f5)',
|
||||
padding: '0 16px',
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #d9d9d9)'
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--ant-color-text, rgba(0,0,0,0.88))' }}>
|
||||
{entry.name} {isDirty && '*'} {truncated && '(大文件仅预览前 1MB,编辑与保存已禁用)'}
|
||||
</span>
|
||||
<Space>
|
||||
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty || truncated}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ position: 'relative', overflow: 'auto', height: 'calc(100% - 40px)' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
isMarkdown ? (
|
||||
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview={truncated ? 'preview' : 'live'}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={<Spin style={{ marginTop: 24 }} />}>
|
||||
<MonacoEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
language={monacoLanguage}
|
||||
options={{
|
||||
readOnly: truncated,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { TextEditorApp } from './TextEditor.tsx';
|
||||
|
||||
const supportedExts = [
|
||||
// Text formats
|
||||
'txt', 'md', 'markdown', 'log',
|
||||
// Data formats
|
||||
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
|
||||
// Web technologies
|
||||
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
|
||||
// Programming languages
|
||||
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
|
||||
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
|
||||
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
|
||||
// Database
|
||||
'sql',
|
||||
// Shell and scripts
|
||||
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
// Build and config files
|
||||
'dockerfile', 'makefile', 'gradle', 'cmake',
|
||||
// Other common text files
|
||||
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
|
||||
];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'text-editor',
|
||||
name: '文本编辑器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
|
||||
description: '内置文本/代码编辑器,支持常见文本与代码格式。',
|
||||
author: 'Foxel',
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
// Supports common text and code formats
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: TextEditorApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
|
||||
};
|
||||
@@ -1,708 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Flex,
|
||||
Image,
|
||||
Input,
|
||||
List,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import { PlayCircleOutlined, ReloadOutlined, SearchOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||
import type { AppOpenComponentProps } from '../types';
|
||||
import { videoLibraryApi, type VideoLibraryItem } from '../../api/videoLibrary';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { ensureAppsLoaded, getAppByKey } from '../registry';
|
||||
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||
import type { VfsEntry } from '../../api/client';
|
||||
|
||||
type LibraryFilter = 'all' | 'tv' | 'movie';
|
||||
|
||||
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/';
|
||||
|
||||
function tmdbImage(path: string | null | undefined, size: string) {
|
||||
if (!path) return undefined;
|
||||
return `${TMDB_IMAGE_BASE}${size}${path}`;
|
||||
}
|
||||
|
||||
function splitAbsolutePath(fullPath: string): { dir: string; name: string } | null {
|
||||
const normalized = fullPath.replace(/\/+$/, '');
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
if (idx < 0) return null;
|
||||
const dir = idx === 0 ? '/' : normalized.slice(0, idx);
|
||||
const name = normalized.slice(idx + 1);
|
||||
if (!name) return null;
|
||||
return { dir, name };
|
||||
}
|
||||
|
||||
export const VideoLibraryApp: React.FC<AppOpenComponentProps> = () => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const { openWithApp } = useAppWindows();
|
||||
const [q, setQ] = useState('');
|
||||
const [filter, setFilter] = useState<LibraryFilter>('all');
|
||||
const [items, setItems] = useState<VideoLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<VideoLibraryItem | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<any | null>(null);
|
||||
|
||||
const loadLibrary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await videoLibraryApi.list();
|
||||
setItems(list);
|
||||
} catch (err: any) {
|
||||
setItems([]);
|
||||
const msg = err instanceof Error ? err.message : t('Load failed');
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLibrary();
|
||||
}, [loadLibrary]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
let tv = 0;
|
||||
let movie = 0;
|
||||
items.forEach((it) => {
|
||||
if (it.type === 'tv') tv += 1;
|
||||
if (it.type === 'movie') movie += 1;
|
||||
});
|
||||
return { total: items.length, tv, movie };
|
||||
}, [items]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
return items.filter((v) => {
|
||||
if (filter !== 'all' && v.type !== filter) return false;
|
||||
if (!s) return true;
|
||||
const haystack = `${v.title || ''} ${v.overview || ''} ${(v.genres || []).join(' ')}`.toLowerCase();
|
||||
return haystack.includes(s);
|
||||
});
|
||||
}, [filter, items, q]);
|
||||
|
||||
const playByPath = useCallback(async (fullPath: string) => {
|
||||
const splitted = splitAbsolutePath(fullPath);
|
||||
if (!splitted) return;
|
||||
await ensureAppsLoaded();
|
||||
const app = getAppByKey('video-player');
|
||||
if (!app) {
|
||||
message.error(t('App "{key}" not found.', { key: 'video-player' }));
|
||||
return;
|
||||
}
|
||||
const entry: VfsEntry = { name: splitted.name, is_dir: false, size: 0, mtime: 0 };
|
||||
openWithApp(entry, app, splitted.dir);
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const fetchDetail = useCallback(async (item: VideoLibraryItem) => {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const payload = await videoLibraryApi.get(item.id);
|
||||
setDetail(payload);
|
||||
} catch (err: any) {
|
||||
setDetail(null);
|
||||
const msg = err instanceof Error ? err.message : t('Load failed');
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const openDetail = (item: VideoLibraryItem) => {
|
||||
setSelected(item);
|
||||
setDetailOpen(true);
|
||||
fetchDetail(item);
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setDetailOpen(false);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
};
|
||||
|
||||
const renderCover = (item: VideoLibraryItem) => {
|
||||
const label = item.type === 'tv' ? 'TV' : 'Movie';
|
||||
const coverUrl = tmdbImage(item.poster_path, 'w342') || tmdbImage(item.backdrop_path, 'w780');
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '2 / 3',
|
||||
background: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={item.title || label}
|
||||
preview={false}
|
||||
wrapperStyle={{ width: '100%', height: '100%', display: 'block' }}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
fallback="/logo.svg"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(0,0,0,0.48) 0%, rgba(0,0,0,0.12) 40%, rgba(0,0,0,0.45) 100%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
right: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Tag color={item.type === 'tv' ? 'geekblue' : 'gold'} style={{ marginInlineEnd: 0 }}>
|
||||
{label}
|
||||
</Tag>
|
||||
<VideoCameraOutlined style={{ fontSize: 18, opacity: 0.9, color: 'rgba(255,255,255,0.9)' }} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
bottom: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(2,6,23,0.55)',
|
||||
border: '1px solid rgba(255,255,255,0.22)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
boxShadow: token.boxShadowTertiary,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 20 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const detailTitle = useMemo(() => {
|
||||
const d = detail?.tmdb?.detail;
|
||||
if (!d) return selected?.title || '';
|
||||
if (detail?.type === 'tv') return d.name || d.original_name || selected?.title || '';
|
||||
return d.title || d.original_title || selected?.title || '';
|
||||
}, [detail, selected?.title]);
|
||||
|
||||
const detailPosterUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.poster_path, 'w342'), [detail]);
|
||||
const detailBackdropUrl = useMemo(() => tmdbImage(detail?.tmdb?.detail?.backdrop_path, 'w1280'), [detail]);
|
||||
|
||||
const detailGenres = useMemo(() => {
|
||||
const genres = detail?.tmdb?.detail?.genres;
|
||||
if (!Array.isArray(genres)) return [];
|
||||
return genres.map((g: any) => g?.name).filter(Boolean);
|
||||
}, [detail]);
|
||||
|
||||
const detailOverview = useMemo(() => {
|
||||
const overview = detail?.tmdb?.detail?.overview;
|
||||
if (!overview) return '';
|
||||
return String(overview);
|
||||
}, [detail]);
|
||||
|
||||
const castTop = useMemo(() => {
|
||||
const cast = detail?.tmdb?.detail?.credits?.cast;
|
||||
if (!Array.isArray(cast)) return [];
|
||||
return cast.slice(0, 12);
|
||||
}, [detail]);
|
||||
|
||||
const episodesBySeason = useMemo(() => {
|
||||
if (detail?.type !== 'tv') return [];
|
||||
const episodes = Array.isArray(detail?.episodes) ? detail.episodes : [];
|
||||
const map = new Map<number, any[]>();
|
||||
episodes.forEach((ep: any) => {
|
||||
const season = typeof ep?.season === 'number' ? ep.season : 1;
|
||||
if (!map.has(season)) map.set(season, []);
|
||||
map.get(season)!.push(ep);
|
||||
});
|
||||
const seasons = Array.from(map.keys()).sort((a, b) => a - b);
|
||||
return seasons.map((season) => {
|
||||
const list = (map.get(season) || []).slice().sort((a, b) => {
|
||||
const ae = typeof a?.episode === 'number' ? a.episode : 10_000;
|
||||
const be = typeof b?.episode === 'number' ? b.episode : 10_000;
|
||||
return ae - be;
|
||||
});
|
||||
return { season, episodes: list };
|
||||
});
|
||||
}, [detail]);
|
||||
|
||||
const renderHero = () => {
|
||||
const year = detail?.type === 'tv'
|
||||
? (detail?.tmdb?.detail?.first_air_date || '').slice(0, 4)
|
||||
: (detail?.tmdb?.detail?.release_date || '').slice(0, 4);
|
||||
const vote = detail?.tmdb?.detail?.vote_average;
|
||||
const voteText = typeof vote === 'number' ? vote.toFixed(1) : '--';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 16,
|
||||
minHeight: 220,
|
||||
background: detailBackdropUrl
|
||||
? `url(${detailBackdropUrl}) center / cover no-repeat`
|
||||
: 'linear-gradient(135deg, #0b1020 0%, #22314a 55%, #2b3b5c 100%)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(90deg, rgba(2,6,23,0.92) 0%, rgba(2,6,23,0.55) 55%, rgba(2,6,23,0.15) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'relative', display: 'flex', gap: 16, alignItems: 'flex-end' }}>
|
||||
<div style={{ width: 132, flex: 'none' }}>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', boxShadow: token.boxShadowTertiary, border: '1px solid rgba(255,255,255,0.18)' }}>
|
||||
{detailPosterUrl ? (
|
||||
<Image
|
||||
src={detailPosterUrl}
|
||||
alt={detailTitle}
|
||||
preview={false}
|
||||
width={132}
|
||||
height={198}
|
||||
style={{ objectFit: 'cover', display: 'block' }}
|
||||
fallback="/logo.svg"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 132,
|
||||
height: 198,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 0, flex: 1, color: 'rgba(255,255,255,0.92)' }}>
|
||||
<Typography.Title level={3} style={{ margin: 0, color: 'rgba(255,255,255,0.92)' }} ellipsis>
|
||||
{detailTitle}
|
||||
</Typography.Title>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', color: 'rgba(255,255,255,0.72)' }}>
|
||||
{year && <span>{year}</span>}
|
||||
<span>·</span>
|
||||
<span>TMDB {voteText}</span>
|
||||
{detail?.type === 'tv' && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{episodesBySeason.reduce((acc, s) => acc + s.episodes.length, 0)} {t('Episodes')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{(detailGenres || []).slice(0, 6).map((g: string) => (
|
||||
<Tag key={g} color="geekblue" style={{ marginInlineEnd: 0 }}>
|
||||
{g}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
{detail?.type === 'movie' && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => playByPath(String(detail?.source_path || ''))}
|
||||
disabled={!detail?.source_path}
|
||||
>
|
||||
{t('Play')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMeta = () => {
|
||||
if (!detail?.tmdb?.detail) return null;
|
||||
const d = detail.tmdb.detail;
|
||||
const isTv = detail?.type === 'tv';
|
||||
const release = isTv ? d.first_air_date : d.release_date;
|
||||
const runtime = isTv ? (Array.isArray(d.episode_run_time) ? d.episode_run_time[0] : undefined) : d.runtime;
|
||||
const status = d.status;
|
||||
const language = d.original_language;
|
||||
const origin = isTv ? d.original_name : d.original_title;
|
||||
const seasons = isTv ? d.number_of_seasons : undefined;
|
||||
const eps = isTv ? d.number_of_episodes : undefined;
|
||||
|
||||
return (
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={2}
|
||||
styles={{ label: { width: 110, color: token.colorTextSecondary } }}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
<Descriptions.Item label={t('Type')}>{isTv ? t('TV') : t('Movies')}</Descriptions.Item>
|
||||
<Descriptions.Item label="TMDB ID">{String(detail?.tmdb?.id || '')}</Descriptions.Item>
|
||||
{origin && <Descriptions.Item label={t('Original Title')}>{String(origin)}</Descriptions.Item>}
|
||||
{release && <Descriptions.Item label={t('Release Date')}>{String(release)}</Descriptions.Item>}
|
||||
{status && <Descriptions.Item label={t('Status')}>{String(status)}</Descriptions.Item>}
|
||||
{runtime && <Descriptions.Item label={t('Runtime')}>{String(runtime)} min</Descriptions.Item>}
|
||||
{language && <Descriptions.Item label={t('Language')}>{String(language).toUpperCase()}</Descriptions.Item>}
|
||||
{isTv && seasons !== undefined && <Descriptions.Item label={t('Seasons')}>{String(seasons)}</Descriptions.Item>}
|
||||
{isTv && eps !== undefined && <Descriptions.Item label={t('Episodes')}>{String(eps)}</Descriptions.Item>}
|
||||
{detail?.source_path && <Descriptions.Item label={t('Source Path')} span={2}>{String(detail.source_path)}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCast = () => {
|
||||
if (!castTop.length) return null;
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('Cast')}
|
||||
</Typography.Title>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{castTop.map((c: any) => {
|
||||
const avatar = tmdbImage(c?.profile_path, 'w185');
|
||||
return (
|
||||
<div
|
||||
key={String(c?.id || `${c?.name}-${c?.character}`)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: token.colorFillQuaternary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
<Avatar size={36} src={avatar} style={{ flex: 'none' }}>
|
||||
{(c?.name || '?').slice(0, 1)}
|
||||
</Avatar>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 140 }}>
|
||||
{c?.name || '--'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
|
||||
{c?.character || ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEpisodes = () => {
|
||||
if (detail?.type !== 'tv') return null;
|
||||
if (!episodesBySeason.length) {
|
||||
return (
|
||||
<Empty description={t('No data')} style={{ marginTop: 24 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse
|
||||
accordion={false}
|
||||
items={episodesBySeason.map(({ season, episodes }) => ({
|
||||
key: String(season),
|
||||
label: `${t('Season')} ${season} · ${episodes.length} ${t('Episodes')}`,
|
||||
children: (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={episodes}
|
||||
renderItem={(ep: any) => {
|
||||
const seasonNo = typeof ep?.season === 'number' ? ep.season : season;
|
||||
const epNo = typeof ep?.episode === 'number' ? ep.episode : undefined;
|
||||
const tmdbEp = ep?.tmdb_episode || {};
|
||||
const still = tmdbImage(tmdbEp?.still_path, 'w300');
|
||||
const title = tmdbEp?.name || ep?.name || ep?.rel || '--';
|
||||
const air = tmdbEp?.air_date ? String(tmdbEp.air_date) : '';
|
||||
const runtime = tmdbEp?.runtime ? `${tmdbEp.runtime} min` : '';
|
||||
const sub = [air, runtime].filter(Boolean).join(' · ');
|
||||
const prefix = epNo !== undefined ? `S${String(seasonNo).padStart(2, '0')}E${String(epNo).padStart(2, '0')}` : `S${String(seasonNo).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{ paddingInline: 0 }}
|
||||
actions={[
|
||||
<Button
|
||||
key="play"
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => playByPath(String(ep?.path || ''))}
|
||||
disabled={!ep?.path}
|
||||
>
|
||||
{t('Play')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={still ? (
|
||||
<Image
|
||||
src={still}
|
||||
preview={false}
|
||||
width={120}
|
||||
height={68}
|
||||
style={{ objectFit: 'cover', borderRadius: 10, overflow: 'hidden' }}
|
||||
fallback="/logo.svg"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 120,
|
||||
height: 68,
|
||||
borderRadius: 10,
|
||||
background: token.colorFillQuaternary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
title={(
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Tag style={{ marginInlineEnd: 0 }}>{prefix}</Tag>
|
||||
<Typography.Text strong ellipsis style={{ display: 'block', maxWidth: 360 }}>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
description={sub ? <Typography.Text type="secondary">{sub}</Typography.Text> : null}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: token.colorBgLayout }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
background: token.colorBgContainer,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
||||
<div style={{ minWidth: 240 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{t('Video Library')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Total')}: {stats.total} · {t('Movies')}: {stats.movie} · {t('TV')}: {stats.tv}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space size={10} wrap>
|
||||
<Segmented
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as LibraryFilter)}
|
||||
options={[
|
||||
{ value: 'all', label: t('All') },
|
||||
{ value: 'movie', label: t('Movies') },
|
||||
{ value: 'tv', label: t('TV') },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('Search')}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadLibrary} loading={loading}>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px 2px' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
|
||||
{Array.from({ length: 10 }).map((_, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
size="small"
|
||||
style={{ borderRadius: 12, overflow: 'hidden' }}
|
||||
cover={(
|
||||
<div style={{ width: '100%', aspectRatio: '2 / 3' }}>
|
||||
<Skeleton.Image active style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Skeleton active paragraph={{ rows: 2 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty description={t('No data')} style={{ marginTop: 48 }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: 12 }}>
|
||||
{filtered.map((v) => (
|
||||
<Card
|
||||
key={v.id}
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 10 } } as any}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
boxShadow: token.boxShadowTertiary,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
cover={renderCover(v)}
|
||||
onClick={() => openDetail(v)}
|
||||
>
|
||||
<Typography.Text strong ellipsis style={{ display: 'block' }}>
|
||||
{v.title || '--'}
|
||||
</Typography.Text>
|
||||
<div style={{ marginTop: 6, display: 'flex', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
<span>{v.year || '--'}</span>
|
||||
{v.type === 'tv' ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{v.episodes_count || 0} {t('Episodes')}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{(v.genres || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} style={{ marginInlineEnd: 0 }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{(v.genres || []).length > 3 && (
|
||||
<Tag style={{ marginInlineEnd: 0 }}>+{(v.genres || []).length - 3}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={detailTitle || selected?.title || t('Details')}
|
||||
open={detailOpen}
|
||||
onClose={closeDetail}
|
||||
width="100%"
|
||||
destroyOnHidden
|
||||
getContainer={false}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<Empty description={t('No data')} style={{ marginTop: 48 }} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{renderHero()}
|
||||
<div style={{ padding: 16 }}>
|
||||
<Tabs
|
||||
items={[
|
||||
...(detail?.type === 'tv'
|
||||
? [{
|
||||
key: 'episodes',
|
||||
label: t('Episodes'),
|
||||
children: renderEpisodes(),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'detail',
|
||||
label: t('Details'),
|
||||
children: (
|
||||
<>
|
||||
{renderMeta()}
|
||||
{detailOverview && (
|
||||
<>
|
||||
<Divider style={{ margin: '14px 0' }} />
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('Overview')}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph style={{ marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||||
{detailOverview}
|
||||
</Typography.Paragraph>
|
||||
</>
|
||||
)}
|
||||
{renderCast()}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Artplayer from 'artplayer';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
|
||||
|
||||
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
const artInstance = useRef<Artplayer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
//
|
||||
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
|
||||
const videoUrl = vfsApi.streamUrl(safePath);
|
||||
|
||||
if (artRef.current) {
|
||||
artInstance.current = new Artplayer({
|
||||
container: artRef.current,
|
||||
url: videoUrl,
|
||||
autoplay: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
pip: true,
|
||||
setting: true,
|
||||
playbackRate: true,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (artInstance.current) {
|
||||
artInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [filePath]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={artRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#000'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
import { VideoLibraryApp } from './VideoLibrary.tsx';
|
||||
|
||||
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'video-player',
|
||||
name: '视频播放器',
|
||||
iconUrl: 'https://api.iconify.design/mdi:video.svg',
|
||||
description: '内置视频播放器,支持常见视频格式播放。',
|
||||
author: 'Foxel',
|
||||
openAppComponent: VideoLibraryApp,
|
||||
supportedExts,
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return supportedExts.includes(ext);
|
||||
},
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
|
||||
};
|
||||
@@ -3,76 +3,112 @@ import type { AppDescriptor } from './types';
|
||||
import React from 'react';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
|
||||
import { getPluginAssetUrl } from '../plugins/runtime';
|
||||
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
// 使用 import.meta.glob 动态导入所有应用
|
||||
const appModules = import.meta.glob('./*/index.ts');
|
||||
/**
|
||||
* 获取插件的唯一 key
|
||||
*/
|
||||
function getPluginAppKey(p: PluginItem): string {
|
||||
return `plugin:${p.key}`;
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
for (const path in appModules) {
|
||||
const module = await appModules[path]();
|
||||
if (module && typeof module === 'object' && 'descriptor' in module) {
|
||||
const descriptor = (module as { descriptor: AppDescriptor }).descriptor;
|
||||
if (!apps.find(a => a.key === descriptor.key)) {
|
||||
apps.push(descriptor);
|
||||
/**
|
||||
* 解析插件图标 URL
|
||||
* 支持绝对路径、相对路径(插件资源)、外部 URL
|
||||
*/
|
||||
function resolvePluginIcon(p: PluginItem): string | undefined {
|
||||
if (!p.icon) return undefined;
|
||||
|
||||
// 外部 URL
|
||||
if (p.icon.startsWith('http://') || p.icon.startsWith('https://')) {
|
||||
return p.icon;
|
||||
}
|
||||
|
||||
// 绝对路径
|
||||
if (p.icon.startsWith('/')) {
|
||||
return p.icon;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
|
||||
} catch { void 0; }
|
||||
|
||||
// 插件资源路径
|
||||
return getPluginAssetUrl(p.key, p.icon);
|
||||
}
|
||||
|
||||
function resolvePluginUseSystemWindow(p: PluginItem): boolean | undefined {
|
||||
const frontend = (p.manifest as any)?.frontend as any;
|
||||
const value = frontend?.use_system_window ?? frontend?.useSystemWindow;
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function registerPluginAsApp(p: PluginItem) {
|
||||
const key = 'plugin:' + p.id;
|
||||
if (apps.find(a => a.key === key)) return;
|
||||
const key = getPluginAppKey(p);
|
||||
if (apps.find((a) => a.key === key)) return;
|
||||
|
||||
const supported = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!p.supported_exts || p.supported_exts.length === 0) return true;
|
||||
return p.supported_exts.includes(ext);
|
||||
};
|
||||
|
||||
apps.push({
|
||||
key,
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
name: p.name || `插件 ${p.key}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
openAppComponent: p.open_app ? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })) : undefined,
|
||||
iconUrl: p.icon || undefined,
|
||||
openAppComponent: p.open_app
|
||||
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
|
||||
: undefined,
|
||||
iconUrl: resolvePluginIcon(p),
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
useSystemWindow: resolvePluginUseSystemWindow(p),
|
||||
description: p.description || undefined,
|
||||
author: p.author || undefined,
|
||||
supportedExts: p.supported_exts || undefined,
|
||||
website: p.website || undefined,
|
||||
github: p.github || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
items.forEach((p) => registerPluginAsApp(p));
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
|
||||
const appsLoadedPromise = loadApps();
|
||||
|
||||
export async function ensureAppsLoaded() {
|
||||
await appsLoadedPromise;
|
||||
}
|
||||
|
||||
export function listSystemApps(): AppDescriptor[] {
|
||||
return apps.filter(a => !a.key.startsWith('plugin:'));
|
||||
export function listPluginApps(): AppDescriptor[] {
|
||||
return apps;
|
||||
}
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter(a => a.supported(entry));
|
||||
return apps.filter((a) => a.supported(entry));
|
||||
}
|
||||
|
||||
export function getAppByKey(key: string): AppDescriptor | undefined {
|
||||
return apps.find(a => a.key === key);
|
||||
return apps.find((a) => a.key === key);
|
||||
}
|
||||
|
||||
export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefined {
|
||||
if (entry.is_dir) return;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!ext) return apps.find(a => a.supported(entry) && a.default);
|
||||
if (!ext) return apps.find((a) => a.supported(entry) && a.default);
|
||||
const saved = localStorage.getItem(`app.default.${ext}`);
|
||||
if (saved) {
|
||||
return apps.find(a => a.key === saved && a.supported(entry)) || undefined;
|
||||
return apps.find((a) => a.key === saved && a.supported(entry)) || undefined;
|
||||
}
|
||||
return apps.find(a => a.supported(entry) && a.default);
|
||||
return apps.find((a) => a.supported(entry) && a.default);
|
||||
}
|
||||
|
||||
export type { AppDescriptor };
|
||||
@@ -81,27 +117,40 @@ export type { AppComponentProps } from './types';
|
||||
export async function reloadPluginApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
|
||||
|
||||
// 生成要保留的 key 集合
|
||||
const keepKeys = new Set(items.map((p) => getPluginAppKey(p)));
|
||||
|
||||
// 移除已卸载的插件应用
|
||||
for (let i = apps.length - 1; i >= 0; i--) {
|
||||
const a = apps[i];
|
||||
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
|
||||
if (!keepKeys.has(a.key)) {
|
||||
apps.splice(i, 1);
|
||||
}
|
||||
}
|
||||
items.filter(p => p.enabled !== false).forEach(p => {
|
||||
const key = 'plugin:' + p.id;
|
||||
const existing = apps.find(a => a.key === key);
|
||||
|
||||
// 更新或添加插件应用
|
||||
items.forEach((p) => {
|
||||
const key = getPluginAppKey(p);
|
||||
const existing = apps.find((a) => a.key === key);
|
||||
if (!existing) {
|
||||
registerPluginAsApp(p);
|
||||
} else {
|
||||
existing.name = p.name || `插件 ${p.id}`;
|
||||
// 更新现有应用信息
|
||||
existing.name = p.name || `插件 ${p.key}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
existing.iconUrl = p.icon || existing.iconUrl;
|
||||
existing.useSystemWindow = resolvePluginUseSystemWindow(p);
|
||||
existing.iconUrl = resolvePluginIcon(p);
|
||||
existing.description = p.description || undefined;
|
||||
existing.author = p.author || undefined;
|
||||
existing.supportedExts = p.supported_exts || undefined;
|
||||
existing.openAppComponent = p.open_app
|
||||
? ((props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props }))
|
||||
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
} catch { void 0; }
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,174 @@ body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto
|
||||
.fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
|
||||
.fx-fade-text { color: var(--ant-color-text-secondary, #555); }
|
||||
|
||||
.fx-center-card {
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
border: 1px solid var(--ant-color-border, #eaeaea);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 24px -18px rgba(0,0,0,.22);
|
||||
}
|
||||
.fx-center-card-hero {
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--ant-color-fill-secondary, #f3f3f3), var(--ant-color-fill-tertiary, #f5f5f5));
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-hero {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
}
|
||||
.fx-center-card-iconbox {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-iconbox {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.28);
|
||||
}
|
||||
.fx-center-card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.fx-center-card-pills {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.fx-center-pill {
|
||||
max-width: 120px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
background: rgba(255,255,255,0.72);
|
||||
color: rgba(0,0,0,0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-theme="dark"] .fx-center-pill {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.28);
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.fx-center-pill-success {
|
||||
border-color: rgba(82,196,26,0.35);
|
||||
background: rgba(82,196,26,0.14);
|
||||
color: var(--ant-color-success, #52c41a);
|
||||
}
|
||||
.fx-center-card-body {
|
||||
padding: 14px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fx-center-card-titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.fx-center-card-title {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
color: var(--ant-color-text, #111);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fx-center-card-version {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fx-center-card-metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
}
|
||||
.fx-center-card-metaLeft,
|
||||
.fx-center-card-metaRight {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fx-center-card-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ant-color-text-secondary, #555);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 42px;
|
||||
}
|
||||
.fx-center-card-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||
background: var(--ant-color-fill-tertiary, #fafafa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-footer {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.fx-center-card-footerLeft,
|
||||
.fx-center-card-footerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fx-center-card-actions,
|
||||
.fx-center-card-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fx-center-card-iconLink {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
transition: background .18s ease, color .18s ease;
|
||||
}
|
||||
.fx-center-card-iconLink:hover {
|
||||
background: var(--ant-color-fill-secondary, #f2f2f2);
|
||||
color: var(--ant-color-text, #111);
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-iconLink:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
|
||||
|
||||
/* 使用 antd 默认布局背景 */
|
||||
|
||||
@@ -34,6 +34,7 @@ export function I18nProvider({ children }: PropsWithChildren) {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
window.dispatchEvent(new CustomEvent('foxel:langchange', { detail: { lang } }));
|
||||
}, [lang]);
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string | number>) => {
|
||||
|
||||
@@ -605,18 +605,31 @@
|
||||
"Up": "Up",
|
||||
"Select Current Folder": "Select Current Folder",
|
||||
"Please select a file": "Please select a file",
|
||||
"Please select a .foxpkg file": "Please select a .foxpkg file",
|
||||
"Invalid file": "Invalid file",
|
||||
"Installed successfully": "Installed successfully",
|
||||
"Installation failed": "Installation failed",
|
||||
"Plugin": "Plugin",
|
||||
"System App": "System App",
|
||||
"No description": "No description",
|
||||
"Any": "Any",
|
||||
"Open Link": "Open Link",
|
||||
"Link copied": "Link copied",
|
||||
"Copy Link": "Copy Link",
|
||||
"Open App": "Open App",
|
||||
"Update App": "Update App",
|
||||
"Confirm delete this plugin?": "Confirm delete this plugin?",
|
||||
"Uninstall": "Uninstall",
|
||||
"Author": "Author",
|
||||
"Website": "Website",
|
||||
"Install Plugin": "Install Plugin",
|
||||
"Confirm Install": "Confirm Install",
|
||||
"Selected {count} files": "Selected {count} files",
|
||||
"Installation will stop on first failure": "Installation will stop on first failure",
|
||||
"Installing": "Installing",
|
||||
"Remove": "Remove",
|
||||
"Cancel": "Cancel",
|
||||
"Install App": "Install App",
|
||||
"Search name/author/extension": "Search name/author/extension",
|
||||
"Search name/author/url/extension": "Search name/author/url/extension",
|
||||
"No plugins": "No plugins",
|
||||
"Install": "Install",
|
||||
@@ -630,6 +643,13 @@
|
||||
"Created (newest)": "Created (newest)",
|
||||
"Installed already": "Installed",
|
||||
"No results": "No results",
|
||||
"Download and Install": "Download & Install",
|
||||
"Loading apps": "Loading apps",
|
||||
"Failed to load apps": "Failed to load apps",
|
||||
"Version": "Version",
|
||||
"Tags": "Tags",
|
||||
"Approved": "Approved",
|
||||
"Coming soon v2": "Coming soon v2",
|
||||
"Initialization succeeded! Logging you in...": "Initialization succeeded! Logging you in...",
|
||||
"Initialization failed, please try later": "Initialization failed, please try later",
|
||||
"Database Setup": "Database Setup",
|
||||
|
||||
@@ -598,18 +598,30 @@
|
||||
"Up": "上一级",
|
||||
"Select Current Folder": "选择当前目录",
|
||||
"Please select a file": "请选择一个文件",
|
||||
"Please select a .foxpkg file": "请选择一个 .foxpkg 文件",
|
||||
"Invalid file": "无效文件",
|
||||
"Installed successfully": "安装成功",
|
||||
"Installation failed": "安装失败",
|
||||
"Plugin": "插件",
|
||||
"System App": "系统应用",
|
||||
"No description": "暂无描述",
|
||||
"Any": "任意",
|
||||
"Open Link": "打开链接",
|
||||
"Link copied": "已复制链接",
|
||||
"Copy Link": "复制链接",
|
||||
"Open App": "打开应用",
|
||||
"Update App": "更新应用",
|
||||
"Confirm delete this plugin?": "确认删除该插件?",
|
||||
"Uninstall": "卸载",
|
||||
"Author": "作者",
|
||||
"Website": "官网",
|
||||
"Install Plugin": "安装应用",
|
||||
"Confirm Install": "确认安装",
|
||||
"Selected {count} files": "已选择 {count} 个文件",
|
||||
"Installation will stop on first failure": "遇到失败将停止后续安装",
|
||||
"Installing": "安装中",
|
||||
"Remove": "移除",
|
||||
"Install App": "安装应用",
|
||||
"Search name/author/extension": "搜索 名称/作者/扩展名",
|
||||
"Search name/author/url/extension": "搜索 名称/作者/链接/扩展名",
|
||||
"No plugins": "暂无插件",
|
||||
"Install": "安装",
|
||||
@@ -623,6 +635,14 @@
|
||||
"Created (newest)": "创建时间(最新)",
|
||||
"Installed already": "已安装",
|
||||
"No results": "暂无结果",
|
||||
"Downloading": "下载中",
|
||||
"Download and Install": "下载并安装",
|
||||
"Loading apps": "加载应用中",
|
||||
"Failed to load apps": "加载应用失败",
|
||||
"Version": "版本",
|
||||
"Tags": "标签",
|
||||
"Approved": "已审核",
|
||||
"Coming soon v2": "敬请期待 v2",
|
||||
"Initialization succeeded! Logging you in...": "初始化成功!正在为您登录,请不要刷新。",
|
||||
"Initialization failed, please try later": "初始化失败,请稍后重试",
|
||||
"Database Setup": "数据库设置",
|
||||
|
||||
@@ -4,6 +4,10 @@ import 'antd/dist/reset.css';
|
||||
import './global.css';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
// 初始化插件依赖注入
|
||||
import { initExternals } from './plugins/externals';
|
||||
initExternals();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
247
web/src/plugin-frame.ts
Normal file
247
web/src/plugin-frame.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import 'antd/dist/reset.css';
|
||||
import './global.css';
|
||||
|
||||
import { initExternals } from './plugins/externals';
|
||||
import type { FoxelHostApi, RegisteredPlugin } from './plugins/externals';
|
||||
import type { PluginItem } from './api/plugins';
|
||||
import { pluginsApi } from './api/plugins';
|
||||
import request from './api/client';
|
||||
import { vfsApi, type VfsEntry } from './api/vfs';
|
||||
|
||||
type FrameMode = 'file' | 'app';
|
||||
|
||||
function renderStatus(text: string, isError: boolean = false) {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return;
|
||||
|
||||
root.innerHTML = '';
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = [
|
||||
'height:100%',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'padding:16px',
|
||||
'box-sizing:border-box',
|
||||
`color:${isError ? 'red' : 'rgba(0,0,0,0.65)'}`,
|
||||
'font-size:14px',
|
||||
'white-space:pre-wrap',
|
||||
].join(';');
|
||||
el.textContent = text;
|
||||
root.appendChild(el);
|
||||
}
|
||||
|
||||
function getQuery() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const pluginKey = (params.get('pluginKey') || '').trim();
|
||||
const mode = (params.get('mode') || 'file') as FrameMode;
|
||||
const filePath = (params.get('filePath') || '').trim();
|
||||
return { pluginKey, mode, filePath };
|
||||
}
|
||||
|
||||
function postToParent(data: any) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage(data, window.location.origin);
|
||||
}
|
||||
}
|
||||
|
||||
function createHostApi(pluginKey: string): FoxelHostApi {
|
||||
const showMessage: FoxelHostApi['showMessage'] = (type, content) => {
|
||||
const antd = window.__FOXEL_EXTERNALS__?.antd;
|
||||
const msg = antd?.message;
|
||||
if (!msg) return;
|
||||
switch (type) {
|
||||
case 'success':
|
||||
msg.success(content);
|
||||
break;
|
||||
case 'error':
|
||||
msg.error(content);
|
||||
break;
|
||||
case 'warning':
|
||||
msg.warning(content);
|
||||
break;
|
||||
case 'info':
|
||||
default:
|
||||
msg.info(content);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
close: () => postToParent({ type: 'foxel-plugin:close', pluginKey }),
|
||||
openPath: (path) => postToParent({ type: 'foxel-plugin:openPath', pluginKey, path }),
|
||||
openFile: (filePath, appKey) =>
|
||||
postToParent({ type: 'foxel-plugin:openFile', pluginKey, filePath, appKey }),
|
||||
showMessage,
|
||||
callApi: async <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) =>
|
||||
request<T>(path, options),
|
||||
getTempLink: async (filePath: string) => {
|
||||
const token = await vfsApi.getTempLinkToken(filePath);
|
||||
return vfsApi.getTempPublicUrl(token.token);
|
||||
},
|
||||
getStreamUrl: (filePath: string) => vfsApi.streamUrl(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
function getPluginStylePaths(plugin: PluginItem): string[] {
|
||||
const styles = (plugin.manifest as any)?.frontend?.styles as unknown;
|
||||
if (!Array.isArray(styles)) return [];
|
||||
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
||||
}
|
||||
|
||||
async function loadPluginStyles(pluginKey: string, plugin: PluginItem) {
|
||||
const stylePaths = getPluginStylePaths(plugin);
|
||||
if (stylePaths.length === 0) return;
|
||||
|
||||
const tasks = stylePaths.map(
|
||||
(p) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const href = `/api/plugins/${pluginKey}/assets/${p.replace(/^\/+/, '')}`;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
link.onload = () => resolve();
|
||||
link.onerror = () => resolve();
|
||||
document.head.appendChild(link);
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
async function loadPluginBundle(pluginKey: string): Promise<RegisteredPlugin> {
|
||||
const url = `/api/plugins/${pluginKey}/bundle.js`;
|
||||
|
||||
return new Promise<RegisteredPlugin>((resolve, reject) => {
|
||||
let done = false;
|
||||
const t = window.setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
reject(new Error('Plugin did not call FoxelRegister'));
|
||||
}, 15000);
|
||||
|
||||
window.FoxelRegister = (p: RegisteredPlugin) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
window.clearTimeout(t);
|
||||
resolve(p);
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
window.clearTimeout(t);
|
||||
reject(new Error('Failed to load plugin script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function buildFileContext(filePath: string) {
|
||||
const stat = (await vfsApi.stat(filePath)) as any;
|
||||
const name =
|
||||
typeof stat?.name === 'string' && stat.name.trim().length > 0
|
||||
? stat.name
|
||||
: filePath.replace(/\\/g, '/').split('/').filter(Boolean).pop() || 'unknown';
|
||||
|
||||
const entry: VfsEntry = {
|
||||
name,
|
||||
is_dir: Boolean(stat?.is_dir),
|
||||
size: Number(stat?.size || 0),
|
||||
mtime: Number(stat?.mtime || 0),
|
||||
type: typeof stat?.type === 'string' ? stat.type : undefined,
|
||||
has_thumbnail: Boolean(stat?.has_thumbnail),
|
||||
};
|
||||
|
||||
const token = await vfsApi.getTempLinkToken(filePath);
|
||||
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
|
||||
const streamUrl = vfsApi.streamUrl(filePath);
|
||||
|
||||
return { entry, urls: { downloadUrl, streamUrl } };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
initExternals();
|
||||
|
||||
const { pluginKey, mode, filePath } = getQuery();
|
||||
if (!pluginKey) {
|
||||
renderStatus('Missing pluginKey in query string', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (!root) {
|
||||
renderStatus('Missing #root container', true);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStatus('Loading plugin...');
|
||||
|
||||
let plugin: PluginItem;
|
||||
try {
|
||||
plugin = await pluginsApi.get(pluginKey);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
renderStatus(`Failed to load plugin info: ${msg}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadPluginStyles(pluginKey, plugin);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let registered: RegisteredPlugin;
|
||||
try {
|
||||
registered = await loadPluginBundle(pluginKey);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
renderStatus(`Failed to load plugin bundle: ${msg}`, true);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = createHostApi(pluginKey);
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
const mountError = async () => {
|
||||
try {
|
||||
root.innerHTML = '';
|
||||
|
||||
if (mode === 'app') {
|
||||
if (typeof registered.mountApp !== 'function') {
|
||||
throw new Error('This plugin does not support standalone mode');
|
||||
}
|
||||
const ret = await registered.mountApp(root, { host });
|
||||
if (typeof ret === 'function') cleanup = ret;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
throw new Error('Missing filePath in query string');
|
||||
}
|
||||
|
||||
const { entry, urls } = await buildFileContext(filePath);
|
||||
const ret = await registered.mount(root, { filePath, entry, urls, host });
|
||||
if (typeof ret === 'function') cleanup = ret;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
renderStatus(`Plugin runtime error: ${msg}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
await mountError();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
try {
|
||||
cleanup?.();
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void main();
|
||||
279
web/src/plugins/externals.ts
Normal file
279
web/src/plugins/externals.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 插件外部依赖注入模块
|
||||
*
|
||||
* 宿主应用通过 window.__FOXEL_EXTERNALS__ 暴露共享依赖给插件使用
|
||||
* 插件开发时可以直接从 window.__FOXEL_EXTERNALS__ 获取 React、Antd 等依赖
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import * as antd from 'antd';
|
||||
import * as AntdIcons from '@ant-design/icons';
|
||||
|
||||
// 宿主共享依赖(供插件复用)
|
||||
import MonacoEditor from '@monaco-editor/react';
|
||||
import Artplayer from 'artplayer';
|
||||
|
||||
// API 模块
|
||||
import request, { vfsApi, API_BASE_URL } from '../api/client';
|
||||
import { pluginsApi } from '../api/plugins';
|
||||
|
||||
// 类型定义
|
||||
import type { VfsEntry, DirListing } from '../api/client';
|
||||
import type { PluginItem } from '../api/plugins';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
type Dict = Record<string, string>;
|
||||
type Dicts = Partial<Record<Lang, Dict>>;
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 宿主 API 接口
|
||||
*/
|
||||
export interface FoxelHostApi {
|
||||
/**
|
||||
* 关闭当前插件窗口
|
||||
*/
|
||||
close: () => void;
|
||||
|
||||
/**
|
||||
* 在文件浏览器中打开指定路径
|
||||
*/
|
||||
openPath?: (path: string) => void;
|
||||
|
||||
/**
|
||||
* 使用指定应用打开文件
|
||||
*/
|
||||
openFile?: (filePath: string, appKey?: string) => void;
|
||||
|
||||
/**
|
||||
* 显示消息提示
|
||||
*/
|
||||
showMessage: (type: 'success' | 'error' | 'info' | 'warning', content: string) => void;
|
||||
|
||||
/**
|
||||
* 调用 API
|
||||
*/
|
||||
callApi: <T = unknown>(path: string, options?: RequestInit & { json?: unknown }) => Promise<T>;
|
||||
|
||||
/**
|
||||
* 获取文件的临时公开链接
|
||||
*/
|
||||
getTempLink?: (filePath: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* 获取文件流式播放/下载 URL
|
||||
*/
|
||||
getStreamUrl?: (filePath: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件上下文 - 文件打开模式
|
||||
*/
|
||||
export interface PluginContext {
|
||||
/** 文件路径 */
|
||||
filePath: string;
|
||||
/** 文件信息 */
|
||||
entry: VfsEntry;
|
||||
/** 文件 URL */
|
||||
urls: {
|
||||
/** 临时下载链接 */
|
||||
downloadUrl: string;
|
||||
/** 流式播放链接 */
|
||||
streamUrl?: string;
|
||||
};
|
||||
/** 宿主 API */
|
||||
host: FoxelHostApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件上下文 - 独立应用模式
|
||||
*/
|
||||
export interface PluginAppContext {
|
||||
/** 宿主 API */
|
||||
host: FoxelHostApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已注册的插件接口
|
||||
*/
|
||||
export interface RegisteredPlugin {
|
||||
/**
|
||||
* 挂载插件(文件打开模式)
|
||||
* @returns 可选的清理函数
|
||||
*/
|
||||
mount: (container: HTMLElement, ctx: PluginContext) => void | (() => void) | Promise<void | (() => void)>;
|
||||
|
||||
/**
|
||||
* 卸载插件(文件打开模式)
|
||||
*/
|
||||
unmount?: (container: HTMLElement) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* 挂载独立应用
|
||||
* @returns 可选的清理函数
|
||||
*/
|
||||
mountApp?: (container: HTMLElement, ctx: PluginAppContext) => void | (() => void) | Promise<void | (() => void)>;
|
||||
|
||||
/**
|
||||
* 卸载独立应用
|
||||
*/
|
||||
unmountApp?: (container: HTMLElement) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* VFS API 包装
|
||||
*/
|
||||
const vfsApiWrapper = {
|
||||
list: vfsApi.list,
|
||||
stat: vfsApi.stat,
|
||||
mkdir: vfsApi.mkdir,
|
||||
rename: vfsApi.rename,
|
||||
deletePath: vfsApi.deletePath,
|
||||
copy: vfsApi.copy,
|
||||
move: vfsApi.move,
|
||||
streamUrl: vfsApi.streamUrl,
|
||||
getTempLinkToken: vfsApi.getTempLinkToken,
|
||||
getTempPublicUrl: vfsApi.getTempPublicUrl,
|
||||
readFile: vfsApi.readFile,
|
||||
uploadFile: vfsApi.uploadFile,
|
||||
};
|
||||
|
||||
/**
|
||||
* 暴露给插件的外部依赖
|
||||
*/
|
||||
export interface FoxelExternals {
|
||||
// React 核心
|
||||
React: typeof React;
|
||||
ReactDOM: typeof ReactDOM;
|
||||
|
||||
// UI 库
|
||||
antd: typeof antd;
|
||||
AntdIcons: typeof AntdIcons;
|
||||
|
||||
// 编辑器/播放器
|
||||
MonacoEditor: typeof MonacoEditor;
|
||||
Artplayer: typeof Artplayer;
|
||||
|
||||
// i18n
|
||||
i18n?: {
|
||||
getLang: () => Lang;
|
||||
subscribe: (cb: (lang: Lang) => void) => () => void;
|
||||
create: (dicts: Dicts) => {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
useI18n: () => { lang: Lang; t: (key: string, params?: Record<string, string | number>) => string };
|
||||
};
|
||||
};
|
||||
|
||||
// Foxel API
|
||||
foxelApi: {
|
||||
/** HTTP 请求函数 */
|
||||
request: typeof request;
|
||||
/** VFS 文件系统 API */
|
||||
vfs: typeof vfsApiWrapper;
|
||||
/** 插件管理 API */
|
||||
plugins: typeof pluginsApi;
|
||||
/** API 基础 URL */
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
// 版本信息
|
||||
version: string;
|
||||
}
|
||||
|
||||
// 声明全局类型
|
||||
declare global {
|
||||
interface Window {
|
||||
__FOXEL_EXTERNALS__?: FoxelExternals;
|
||||
FoxelRegister?: (plugin: RegisteredPlugin) => void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化并暴露外部依赖
|
||||
*/
|
||||
export function initExternals(): void {
|
||||
const normalizeLang = (raw: unknown): Lang => (raw === 'en' ? 'en' : 'zh');
|
||||
|
||||
const i18nApi = {
|
||||
getLang: () => normalizeLang(localStorage.getItem('lang')),
|
||||
subscribe: (cb: (lang: Lang) => void) => {
|
||||
const handler = (e: Event) => {
|
||||
const lang = (e as CustomEvent)?.detail?.lang as Lang;
|
||||
cb(normalizeLang(lang));
|
||||
};
|
||||
window.addEventListener('foxel:langchange', handler as any);
|
||||
return () => window.removeEventListener('foxel:langchange', handler as any);
|
||||
},
|
||||
create: (dicts: Dicts) => {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const lang = i18nApi.getLang();
|
||||
const dict = dicts[lang] || {};
|
||||
return interpolate(dict[key] ?? key, params);
|
||||
};
|
||||
|
||||
const useI18n = () => {
|
||||
const [lang, setLang] = React.useState<Lang>(() => i18nApi.getLang());
|
||||
React.useEffect(() => i18nApi.subscribe(setLang), []);
|
||||
const tt = React.useCallback((key: string, params?: Record<string, string | number>) => {
|
||||
const dict = dicts[lang] || {};
|
||||
return interpolate(dict[key] ?? key, params);
|
||||
}, [lang]);
|
||||
return { lang, t: tt };
|
||||
};
|
||||
|
||||
return { t, useI18n };
|
||||
},
|
||||
};
|
||||
|
||||
if (window.__FOXEL_EXTERNALS__) {
|
||||
window.__FOXEL_EXTERNALS__.i18n = i18nApi;
|
||||
window.__FOXEL_EXTERNALS__.MonacoEditor = MonacoEditor;
|
||||
window.__FOXEL_EXTERNALS__.Artplayer = Artplayer;
|
||||
return; // 已初始化
|
||||
}
|
||||
|
||||
window.__FOXEL_EXTERNALS__ = {
|
||||
// React 核心
|
||||
React,
|
||||
ReactDOM,
|
||||
|
||||
// UI 库
|
||||
antd,
|
||||
AntdIcons,
|
||||
|
||||
// 编辑器/播放器
|
||||
MonacoEditor,
|
||||
Artplayer,
|
||||
|
||||
// i18n
|
||||
i18n: i18nApi,
|
||||
|
||||
// Foxel API
|
||||
foxelApi: {
|
||||
request,
|
||||
vfs: vfsApiWrapper,
|
||||
plugins: pluginsApi,
|
||||
baseUrl: API_BASE_URL,
|
||||
},
|
||||
|
||||
// 版本信息
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
console.debug('[Foxel] Plugin externals initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取外部依赖
|
||||
*/
|
||||
export function getExternals(): FoxelExternals | undefined {
|
||||
return window.__FOXEL_EXTERNALS__;
|
||||
}
|
||||
|
||||
// 导出类型供插件 SDK 使用
|
||||
export type { VfsEntry, DirListing, PluginItem };
|
||||
@@ -1,64 +1,55 @@
|
||||
import { pluginsApi, type PluginManifestUpdate, type PluginItem } from '../api/plugins';
|
||||
/**
|
||||
* 插件运行时模块
|
||||
*
|
||||
* 负责:
|
||||
* 1. 加载和管理插件
|
||||
* 2. 处理插件注册
|
||||
*/
|
||||
|
||||
export interface RegisteredPlugin {
|
||||
mount: (container: HTMLElement, ctx: {
|
||||
filePath: string;
|
||||
entry: any;
|
||||
urls: { downloadUrl: string };
|
||||
host: HostApi;
|
||||
}) => void | Promise<void>;
|
||||
unmount?: (container: HTMLElement) => void | Promise<void>;
|
||||
import type { PluginItem } from '../api/plugins';
|
||||
import type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi } from './externals';
|
||||
|
||||
mountApp?: (container: HTMLElement, ctx: { host: HostApi }) => void | Promise<void>;
|
||||
unmountApp?: (container: HTMLElement) => void | Promise<void>;
|
||||
|
||||
key?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
supportedExts?: string[];
|
||||
defaultBounds?: { x?: number; y?: number; width?: number; height?: number };
|
||||
defaultMaximized?: boolean;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
}
|
||||
|
||||
export interface HostApi {
|
||||
close: () => void;
|
||||
}
|
||||
// 重新导出类型
|
||||
export type { RegisteredPlugin, PluginContext, PluginAppContext, FoxelHostApi };
|
||||
|
||||
// 已加载的插件缓存
|
||||
const loadedPlugins = new Map<string, RegisteredPlugin>();
|
||||
// 等待加载的插件回调
|
||||
const waiters = new Map<string, ((p: RegisteredPlugin) => void)[]>();
|
||||
// 已注入的脚本 URL
|
||||
const injected = new Set<string>();
|
||||
|
||||
declare global {
|
||||
interface Window { FoxelRegister?: (plugin: RegisteredPlugin) => void; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局插件注册函数
|
||||
* 插件加载后调用此函数注册自己
|
||||
*/
|
||||
window.FoxelRegister = (plugin: RegisteredPlugin) => {
|
||||
const pendingUrl = sessionStorage.getItem('foxel:pendingPluginUrl') || '';
|
||||
if (pendingUrl) {
|
||||
loadedPlugins.set(pendingUrl, plugin);
|
||||
const resolvers = waiters.get(pendingUrl) || [];
|
||||
resolvers.forEach(fn => fn(plugin));
|
||||
resolvers.forEach((fn) => fn(plugin));
|
||||
waiters.delete(pendingUrl);
|
||||
sessionStorage.removeItem('foxel:pendingPluginUrl');
|
||||
} else {
|
||||
// 回退:使用第一个等待的 URL
|
||||
const anyUrl = Array.from(waiters.keys())[0];
|
||||
if (anyUrl) {
|
||||
loadedPlugins.set(anyUrl, plugin);
|
||||
const resolvers = waiters.get(anyUrl) || [];
|
||||
resolvers.forEach(fn => fn(plugin));
|
||||
resolvers.forEach((fn) => fn(plugin));
|
||||
waiters.delete(anyUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 URL 加载插件
|
||||
*/
|
||||
export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin> {
|
||||
const existing = loadedPlugins.get(url);
|
||||
if (existing) return existing;
|
||||
|
||||
return new Promise<RegisteredPlugin>((resolve, reject) => {
|
||||
const arr = waiters.get(url) || [];
|
||||
arr.push(resolve);
|
||||
@@ -67,7 +58,7 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
|
||||
const ready = loadedPlugins.get(url);
|
||||
if (ready) {
|
||||
const resolvers = waiters.get(url) || [];
|
||||
resolvers.forEach(fn => fn(ready));
|
||||
resolvers.forEach((fn) => fn(ready));
|
||||
waiters.delete(url);
|
||||
return;
|
||||
}
|
||||
@@ -94,52 +85,61 @@ export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin>
|
||||
}, 15000);
|
||||
|
||||
const last = arr[arr.length - 1];
|
||||
arr[arr.length - 1] = (p: RegisteredPlugin) => { clearTimeout(t); last(p); };
|
||||
arr[arr.length - 1] = (p: RegisteredPlugin) => {
|
||||
clearTimeout(t);
|
||||
last(p);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getPluginBundleUrl(pluginId: number) {
|
||||
return `/api/plugins/${pluginId}/bundle.js`;
|
||||
/**
|
||||
* 获取插件 bundle URL
|
||||
*/
|
||||
export function getPluginBundleUrl(pluginKey: string): string {
|
||||
return `/api/plugins/${pluginKey}/bundle.js`;
|
||||
}
|
||||
|
||||
export async function loadPlugin(plugin: Pick<PluginItem, 'id' | 'url'>): Promise<RegisteredPlugin> {
|
||||
const bundleUrl = getPluginBundleUrl(plugin.id);
|
||||
try {
|
||||
/**
|
||||
* 获取插件资源 URL
|
||||
*/
|
||||
export function getPluginAssetUrl(pluginKey: string, assetPath: string): string {
|
||||
return `/api/plugins/${pluginKey}/assets/${assetPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件
|
||||
*/
|
||||
export async function loadPlugin(plugin: Pick<PluginItem, 'key'>): Promise<RegisteredPlugin> {
|
||||
const bundleUrl = getPluginBundleUrl(plugin.key);
|
||||
return await loadPluginFromUrl(bundleUrl);
|
||||
} catch (e) {
|
||||
if (plugin.url && plugin.url !== bundleUrl) {
|
||||
try {
|
||||
return await loadPluginFromUrl(plugin.url);
|
||||
} catch {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
|
||||
const manifest: PluginManifestUpdate = {
|
||||
key: plugin.key,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
open_app: typeof plugin.mountApp === 'function',
|
||||
supported_exts: plugin.supportedExts,
|
||||
default_bounds: plugin.defaultBounds,
|
||||
default_maximized: plugin.defaultMaximized,
|
||||
icon: plugin.icon,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
website: plugin.website,
|
||||
github: plugin.github,
|
||||
};
|
||||
try { console.debug('[foxel] report manifest', pluginId, manifest); } catch { void 0; }
|
||||
const key = `foxel:manifestReported:${pluginId}`;
|
||||
if (sessionStorage.getItem(key) === '1') return;
|
||||
try {
|
||||
await pluginsApi.updateManifest(pluginId, manifest);
|
||||
sessionStorage.setItem(key, '1');
|
||||
} catch {
|
||||
void 0;
|
||||
/**
|
||||
* 检查插件是否已加载
|
||||
*/
|
||||
export function isPluginLoaded(key: string): boolean {
|
||||
const bundleUrl = getPluginBundleUrl(key);
|
||||
return loadedPlugins.has(bundleUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已加载的插件
|
||||
*/
|
||||
export function getLoadedPlugin(key: string): RegisteredPlugin | undefined {
|
||||
const bundleUrl = getPluginBundleUrl(key);
|
||||
return loadedPlugins.get(bundleUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除插件缓存(用于开发/调试)
|
||||
*/
|
||||
export function clearPluginCache(key?: string): void {
|
||||
if (key) {
|
||||
const bundleUrl = getPluginBundleUrl(key);
|
||||
loadedPlugins.delete(bundleUrl);
|
||||
injected.delete(bundleUrl);
|
||||
} else {
|
||||
loadedPlugins.clear();
|
||||
injected.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,21 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
// 代理 API 请求到后端服务器
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
'plugin-frame': 'plugin-frame.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user