mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
feat(import): 统一导入备份入口并支持后端扩展包
集中处理策略与定义、配置备份、数据源和页面插件导入,保留旧入口跳转。新增可信后端及前后端完整包的预检、暂存、重启生效、失败回退和版本管理。 验证:扩展、资源、备份与页面定向 pytest 通过,前端 41 项测试及生产构建通过,并完成桌面、窄屏和明暗主题联调。 Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
@@ -82,7 +82,10 @@ async def upload_page_pack(
|
||||
|
||||
@router.get("")
|
||||
def list_page_packs(request: Request) -> list[PagePackMeta]:
|
||||
return _store(request).list()
|
||||
from app.extensions.packages import ExtensionPackageStore
|
||||
return _store(request).list() + ExtensionPackageStore(request.app.state.repo.store.data_dir).runtime_pages(
|
||||
getattr(request.app.state, "managed_extension_versions", {}),
|
||||
)
|
||||
|
||||
|
||||
# ── 源码导入: .tsx 编译为标准页面插件 (仅源码/桌面部署可发起) ──
|
||||
@@ -166,6 +169,17 @@ def import_source_from_project(request: Request, body: ImportFromProjectBody) ->
|
||||
def get_page_pack_bundle(pack_id: str, request: Request) -> FileResponse:
|
||||
"""分发产物。no-store + ETag(sha256): 热更新产物后浏览器立即取新, 未变时可用缓存协商。"""
|
||||
store = _store(request)
|
||||
if pack_id.startswith("package:"):
|
||||
from app.extensions.packages import ExtensionPackageStore
|
||||
try:
|
||||
bundle = ExtensionPackageStore(request.app.state.repo.store.data_dir).runtime_bundle(
|
||||
pack_id, getattr(request.app.state, "managed_extension_versions", {}),
|
||||
)
|
||||
except (ValueError, OSError, KeyError, TypeError):
|
||||
bundle = None
|
||||
if bundle is None:
|
||||
raise HTTPException(404, "配套后端尚未就绪,页面未启用")
|
||||
return FileResponse(bundle, media_type="text/javascript", headers={"Cache-Control": "no-store"})
|
||||
try:
|
||||
meta = store.get(pack_id)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from typing import Annotated
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.api import ext_pages
|
||||
from app.api.definitions import _uploads
|
||||
from app.custom.source_packages.routes import read_upload, require_management
|
||||
from app.extensions.packages import ExtensionPackageStore
|
||||
from app.services import import_center
|
||||
from app.services.ext_pages import PagePackStore
|
||||
from app.services.package_archive import read_code_archive
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["import-center"])
|
||||
_MANAGE = [Depends(require_management)]
|
||||
|
||||
|
||||
def _store(request: Request) -> ExtensionPackageStore:
|
||||
return ExtensionPackageStore(request.app.state.repo.store.data_dir)
|
||||
|
||||
|
||||
@router.post("/import-center/inspect")
|
||||
async def inspect(request: Request, files: Annotated[list[UploadFile], File()]):
|
||||
try:
|
||||
uploads = await _uploads(files)
|
||||
return await run_in_threadpool(import_center.inspect_uploads, uploads, request.app.state.repo.store.data_dir)
|
||||
except (ValueError, OSError, TypeError, yaml.YAMLError) as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
for file in files:
|
||||
await file.close()
|
||||
|
||||
|
||||
@router.get("/extension-packages")
|
||||
def list_packages(request: Request):
|
||||
return _store(request).list_installed(getattr(request.app.state, "managed_extension_versions", {}))
|
||||
|
||||
|
||||
@router.post("/extension-packages/install", dependencies=_MANAGE)
|
||||
async def install(request: Request, file: Annotated[UploadFile, File()], digest: str = Form(...),
|
||||
revision: str = Form(...), trusted: bool = Form(False)):
|
||||
if not trusted:
|
||||
await file.close()
|
||||
raise HTTPException(403, "请先确认扩展代码来源可信")
|
||||
try:
|
||||
return await run_in_threadpool(_store(request).install, await read_upload(file), digest, revision)
|
||||
except (ValueError, OSError, TypeError, yaml.YAMLError) as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/extension-packages/frontend", dependencies=_MANAGE)
|
||||
async def frontend_for_trial(request: Request, file: Annotated[UploadFile, File()],
|
||||
digest: str = Form(...), trusted: bool = Form(False)):
|
||||
"""Return a trusted candidate for browser-side contract checks; never publish it."""
|
||||
if not trusted:
|
||||
await file.close()
|
||||
raise HTTPException(403, "请先确认扩展代码来源可信")
|
||||
data = await read_upload(file)
|
||||
try:
|
||||
preview = await run_in_threadpool(_store(request).inspect, data)
|
||||
if preview["digest"] != digest:
|
||||
raise ValueError("扩展包与预览不一致")
|
||||
entry = preview["manifest"].get("frontend")
|
||||
if not entry:
|
||||
raise ValueError("扩展包没有前端页面")
|
||||
files = await run_in_threadpool(read_code_archive, data, frontend=True)
|
||||
return Response(files[entry], media_type="text/javascript", headers={"Cache-Control": "no-store"})
|
||||
except (ValueError, OSError, TypeError, yaml.YAMLError) as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
class ManageBody(BaseModel):
|
||||
action: str
|
||||
revision: str | None = None
|
||||
|
||||
|
||||
@router.post("/extension-packages/{extension_id}/manage", dependencies=_MANAGE)
|
||||
def manage(extension_id: str, body: ManageBody, request: Request):
|
||||
try:
|
||||
return _store(request).manage(extension_id, body.action, body.revision)
|
||||
except (ValueError, OSError, TypeError) as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/import-center/page", dependencies=_MANAGE)
|
||||
async def install_page(request: Request, file: Annotated[UploadFile, File()], id: str = Form(...),
|
||||
digest: str = Form(...), trusted: bool = Form(False), overwrite: bool = Form(False)):
|
||||
if not trusted:
|
||||
await file.close()
|
||||
raise HTTPException(403, "请先确认页面代码来源可信")
|
||||
try:
|
||||
data = await file.read(5 * 1024 * 1024 + 1)
|
||||
if hashlib.sha256(data).hexdigest() != digest:
|
||||
raise HTTPException(409, "页面文件与预览不一致,请重新预览")
|
||||
store = PagePackStore(request.app.state.repo.store.data_dir)
|
||||
if store.get(id) and not overwrite:
|
||||
raise HTTPException(409, "页面标识已存在,请确认替换或更换标识")
|
||||
candidate = UploadFile(filename=file.filename, file=io.BytesIO(data))
|
||||
try:
|
||||
if (file.filename or "").lower().endswith(".tsx"):
|
||||
return await ext_pages.import_source_pack(request, candidate, id)
|
||||
return await ext_pages.upload_page_pack(request, id, candidate)
|
||||
finally:
|
||||
await candidate.close()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
|
||||
class ProjectPageBody(BaseModel):
|
||||
namespace: str
|
||||
id: str
|
||||
trusted: bool = False
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
@router.post("/import-center/project", dependencies=_MANAGE)
|
||||
def import_project_page(request: Request, body: ProjectPageBody):
|
||||
if not body.trusted:
|
||||
raise HTTPException(403, "请先确认页面代码来源可信")
|
||||
try:
|
||||
if PagePackStore(request.app.state.repo.store.data_dir).get(body.id) and not body.overwrite:
|
||||
raise HTTPException(409, "页面标识已存在,请确认替换或更换标识")
|
||||
return ext_pages.import_source_from_project(
|
||||
request, ext_pages.ImportFromProjectBody(namespace=body.namespace, id=body.id),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
@@ -66,11 +66,11 @@ async def inspect(file: Annotated[UploadFile, File()]):
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
async def install(file: Annotated[UploadFile, File()], digest: str = Form(...)):
|
||||
async def install(file: Annotated[UploadFile, File()], digest: str = Form(...), revision: str | None = Form(None)):
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
data = await read_upload(file)
|
||||
try:
|
||||
return await run_in_threadpool(packages.install, data, digest)
|
||||
return await run_in_threadpool(packages.install, data, digest, revision)
|
||||
except (ValueError, OSError, TypeError, yaml.YAMLError) as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@@ -7,32 +7,25 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import io
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
|
||||
from app import __version__
|
||||
from app.config import settings
|
||||
from app.services.fs_utils import atomic_write_text
|
||||
from app.services.package_archive import MAX_EXPANDED as MAX_EXPANDED
|
||||
from app.services.package_archive import MAX_UPLOAD as MAX_UPLOAD
|
||||
from app.services.package_archive import compatibility, read_code_archive
|
||||
|
||||
MAX_UPLOAD = 20 * 1024 * 1024
|
||||
MAX_EXPANDED = 80 * 1024 * 1024
|
||||
NAME = re.compile(r"[a-z][a-z0-9_]{0,63}\Z")
|
||||
VERSION = re.compile(r"\d+\.\d+\.\d+(?:[a-zA-Z0-9.+-]*)\Z")
|
||||
ENTRY = re.compile(r"[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*:[a-zA-Z_]\w*\Z")
|
||||
@@ -136,69 +129,8 @@ def validate_manifest(doc: dict):
|
||||
raise ValueError("外部安装的插件不能隐藏卡片")
|
||||
|
||||
|
||||
def compatibility(doc: dict) -> list[str]:
|
||||
errors = []
|
||||
for field, current in (("requires_app", __version__), ("requires_python", platform.python_version())):
|
||||
if doc.get(field) and Version(current) not in SpecifierSet(str(doc[field])):
|
||||
errors.append(f"{field}: 需要 {doc[field]},当前 {current}")
|
||||
platforms = doc.get("platforms", ["any"])
|
||||
current = f"{sys.platform}-{platform.machine().lower()}"
|
||||
if not isinstance(platforms, list) or not all(isinstance(x, str) for x in platforms):
|
||||
raise ValueError("platforms 必须是字符串列表")
|
||||
if "any" not in platforms and current not in platforms:
|
||||
errors.append(f"运行平台不匹配,当前 {current}")
|
||||
requirements = doc.get("requires", [])
|
||||
if not isinstance(requirements, list):
|
||||
raise ValueError("requires 必须是依赖列表")
|
||||
for item in requirements:
|
||||
req = Requirement(item)
|
||||
if req.url:
|
||||
raise ValueError("requires 不接受 URL 或安装脚本")
|
||||
if req.marker and not req.marker.evaluate():
|
||||
continue
|
||||
try:
|
||||
installed = importlib.metadata.version(req.name)
|
||||
if installed not in req.specifier:
|
||||
errors.append(f"依赖 {req.name} 版本不匹配:{installed}")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
errors.append(f"缺少依赖 {req.name}(不会自动修改主程序环境)")
|
||||
return errors
|
||||
|
||||
|
||||
def inspect_archive(data: bytes) -> tuple[dict, dict[str, bytes]]:
|
||||
if len(data) > MAX_UPLOAD:
|
||||
raise ValueError("插件包超过 20 MiB")
|
||||
files = {}
|
||||
folded = set()
|
||||
total = 0
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
if len(archive.infolist()) > 1000:
|
||||
raise ValueError("插件包条目超过 1000")
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
parts = PurePosixPath(name).parts
|
||||
if (not parts or name.startswith("/") or "\\" in name or ":" in name
|
||||
or any(p in (".", "..") or p.endswith((".", " ")) for p in parts)
|
||||
or PurePosixPath(name).as_posix() != name.rstrip("/")
|
||||
or any(p.split(".")[0].upper() in {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(10)], *[f"LPT{i}" for i in range(10)]} for p in parts)):
|
||||
raise ValueError("插件包包含非法路径")
|
||||
if stat.S_IFMT(info.external_attr >> 16) not in (0, stat.S_IFREG, stat.S_IFDIR):
|
||||
raise ValueError("插件包不允许符号链接或特殊文件")
|
||||
if name.casefold().rstrip("/") in folded:
|
||||
raise ValueError("插件包包含重复或大小写冲突的路径")
|
||||
folded.add(name.casefold().rstrip("/"))
|
||||
if info.is_dir():
|
||||
continue
|
||||
total += info.file_size
|
||||
if total > MAX_EXPANDED or info.file_size > MAX_UPLOAD:
|
||||
raise ValueError("插件包解压大小超限")
|
||||
# Executable/native payloads need a separately verified distribution.
|
||||
if PurePosixPath(name).suffix.lower() not in (".py", ".yaml", ".yml", ".json", ".txt", ".md", ".csv"):
|
||||
raise ValueError("首版支持源码包;不接收原生库、wheel、脚本或可执行文件")
|
||||
files[name] = archive.read(info)
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
|
||||
raise ValueError("无法读取 ZIP 插件包") from exc
|
||||
files = read_code_archive(data)
|
||||
if "plugin.yaml" not in files or len(files["plugin.yaml"]) > 64 * 1024:
|
||||
raise ValueError("包根目录需要 plugin.yaml(最大 64 KiB)")
|
||||
doc = yaml.safe_load(files["plugin.yaml"])
|
||||
@@ -225,6 +157,8 @@ def inspect_archive(data: bytes) -> tuple[dict, dict[str, bytes]]:
|
||||
if state and doc["version"] in state["hashes"] and state["hashes"][doc["version"]] != digest:
|
||||
raise ValueError("相同版本已有不同内容,请提升版本号")
|
||||
return {"manifest": doc, "digest": digest, "errors": errors,
|
||||
"pending_version": state.get("pending") if state else None,
|
||||
"revision": hashlib.sha256(json.dumps(state, sort_keys=True).encode()).hexdigest(),
|
||||
"installed_version": state["active"] if state else None}, files
|
||||
|
||||
|
||||
@@ -286,12 +220,14 @@ def build_provider(doc: dict):
|
||||
return provider, "已就绪"
|
||||
|
||||
|
||||
def install(data: bytes, digest: str) -> dict:
|
||||
def install(data: bytes, digest: str, revision: str | None = None) -> dict:
|
||||
from . import loader
|
||||
with mutation():
|
||||
preview, files = inspect_archive(data)
|
||||
if preview["digest"] != digest:
|
||||
raise ValueError("插件包与预览不一致,请重新预览")
|
||||
if revision is not None and preview["revision"] != revision:
|
||||
raise ValueError("插件状态已变化,请重新预览")
|
||||
if preview["errors"]:
|
||||
raise ValueError(";".join(preview["errors"]))
|
||||
doc = preview["manifest"]
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""Versioned backend/page packages, activated only during application startup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from packaging.version import Version
|
||||
|
||||
from app.extensions.contracts import BACKEND_EXTENSION_API_VERSION, ExtensionContext
|
||||
from app.extensions.registry import BackendExtensionRegistrar
|
||||
from app.services.ext_pages import PagePackMeta
|
||||
from app.services.fs_utils import atomic_write_text
|
||||
from app.services.package_archive import compatibility, read_code_archive
|
||||
|
||||
_ID = re.compile(r"[a-z][a-z0-9_]*(?:[.-][a-z0-9_]+)*\Z")
|
||||
_VERSION = re.compile(r"\d+\.\d+\.\d+(?:[a-zA-Z0-9.+-]*)\Z")
|
||||
_MODULE = re.compile(r"[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)*\Z")
|
||||
|
||||
|
||||
class _StateChangedError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _hash(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _contents_hash(files: dict[str, bytes]) -> str:
|
||||
return _hash(json.dumps(sorted((name, _hash(data)) for name, data in files.items())).encode())
|
||||
|
||||
|
||||
def _validate_manifest(doc: dict, files: dict[str, bytes]) -> None:
|
||||
if not isinstance(doc, dict) or doc.get("format") != "tick-extension-package" or type(doc.get("format_version")) is not int or doc["format_version"] != 1:
|
||||
raise ValueError("extension.yaml 需要 tick-extension-package 格式 v1")
|
||||
extension_id = doc.get("id", "")
|
||||
if not isinstance(extension_id, str) or len(extension_id) > 80 or not _ID.fullmatch(extension_id) or extension_id.startswith(("official.", "core.")):
|
||||
raise ValueError("扩展 id 非法或使用了主程序保留命名空间")
|
||||
version = doc.get("version", "")
|
||||
if not isinstance(version, str) or len(version) > 64 or not _VERSION.fullmatch(version):
|
||||
raise ValueError("version 必须是有效版本,例如 1.0.0")
|
||||
Version(version)
|
||||
if type(doc.get("extension_api_version")) is not int or doc["extension_api_version"] != BACKEND_EXTENSION_API_VERSION:
|
||||
raise ValueError("不支持的后端扩展契约版本")
|
||||
for field in ("display_name", "author", "description"):
|
||||
if field in doc and (not isinstance(doc[field], str) or len(doc[field]) > 4000):
|
||||
raise ValueError(f"{field} 必须是文本且不超过 4000 字符")
|
||||
module = doc.get("backend")
|
||||
if not isinstance(module, str) or not _MODULE.fullmatch(module):
|
||||
raise ValueError("backend 必须指定包内模块,例如 backend.extension")
|
||||
path = module.replace(".", "/")
|
||||
if f"{path}.py" not in files and f"{path}/__init__.py" not in files:
|
||||
raise ValueError("backend 指定的模块不存在")
|
||||
frontend = doc.get("frontend")
|
||||
if frontend is not None and (not isinstance(frontend, str) or frontend not in files or PurePosixPath(frontend).suffix not in (".js", ".mjs")):
|
||||
raise ValueError("frontend 必须指向包内自包含的 .js/.mjs 页面产物")
|
||||
for name, payload in files.items():
|
||||
if name.endswith(".py"):
|
||||
try:
|
||||
compile(payload, name, "exec")
|
||||
except (SyntaxError, ValueError) as exc:
|
||||
raise ValueError(f"Python 文件语法错误:{name}") from exc
|
||||
|
||||
|
||||
class ExtensionPackageStore:
|
||||
def __init__(self, data_dir: Path):
|
||||
self.root = Path(data_dir) / "extensions"
|
||||
|
||||
def _directory(self, extension_id: str) -> Path:
|
||||
if not isinstance(extension_id, str) or len(extension_id) > 80 or not _ID.fullmatch(extension_id):
|
||||
raise ValueError("扩展标识非法")
|
||||
path = self.root / extension_id
|
||||
if self.root.is_symlink() or path.is_symlink():
|
||||
raise ValueError("扩展目录不能是符号链接")
|
||||
return path
|
||||
|
||||
def _version_dir(self, extension_id: str, version: str) -> Path:
|
||||
if not isinstance(version, str) or len(version) > 64 or not _VERSION.fullmatch(version):
|
||||
raise ValueError("扩展版本非法")
|
||||
path = self._directory(extension_id) / "versions" / version
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise ValueError("扩展版本目录不能是符号链接")
|
||||
return path
|
||||
|
||||
@contextmanager
|
||||
def mutation(self):
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("扩展目录不能是符号链接")
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
db = self.root / ".management.sqlite"
|
||||
if db.is_symlink():
|
||||
raise ValueError("扩展管理文件不能是符号链接")
|
||||
conn = sqlite3.connect(db, timeout=5)
|
||||
try:
|
||||
conn.execute("BEGIN EXCLUSIVE")
|
||||
yield
|
||||
finally:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
|
||||
def _state(self, extension_id: str) -> dict | None:
|
||||
path = self._directory(extension_id) / "state.json"
|
||||
if path.is_symlink():
|
||||
raise ValueError("扩展记录不能是符号链接")
|
||||
if not path.exists():
|
||||
return None
|
||||
state = json.loads(path.read_text("utf-8"))
|
||||
if not isinstance(state, dict) or state.get("schema") != 1 or not isinstance(state.get("hashes"), dict) or not isinstance(state.get("contents"), dict):
|
||||
raise ValueError("不支持的扩展安装记录")
|
||||
return state
|
||||
|
||||
def _write(self, extension_id: str, state: dict) -> None:
|
||||
path = self._directory(extension_id)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if (path / "state.json").is_symlink():
|
||||
raise ValueError("扩展记录不能是符号链接")
|
||||
atomic_write_text(path / "state.json", json.dumps(state, ensure_ascii=False))
|
||||
|
||||
@staticmethod
|
||||
def _revision(state: dict | None) -> str:
|
||||
return _hash(json.dumps(state, sort_keys=True).encode())
|
||||
|
||||
def inspect(self, data: bytes) -> dict:
|
||||
files = read_code_archive(data, frontend=True)
|
||||
if "extension.yaml" not in files or len(files["extension.yaml"]) > 64 * 1024:
|
||||
raise ValueError("包根目录需要 extension.yaml(最大 64 KiB)")
|
||||
doc = yaml.safe_load(files["extension.yaml"])
|
||||
_validate_manifest(doc, files)
|
||||
state = self._state(doc["id"])
|
||||
digest = _hash(data)
|
||||
if state and doc["version"] in state["hashes"] and state["hashes"][doc["version"]] != digest:
|
||||
raise ValueError("相同版本已有不同内容,请提升版本号")
|
||||
return {
|
||||
"manifest": doc, "digest": digest, "revision": self._revision(state),
|
||||
"errors": compatibility(doc), "files": sorted(files),
|
||||
"installed_version": state.get("active") if state else None,
|
||||
"pending_version": state.get("pending") if state else None,
|
||||
}
|
||||
|
||||
def install(self, data: bytes, digest: str, revision: str) -> dict:
|
||||
with self.mutation():
|
||||
preview = self.inspect(data)
|
||||
if digest != preview["digest"]:
|
||||
raise ValueError("扩展包与预览不一致,请重新预览")
|
||||
if revision != preview["revision"]:
|
||||
raise ValueError("扩展状态已变化,请重新预览")
|
||||
if preview["errors"]:
|
||||
raise ValueError(";".join(preview["errors"]))
|
||||
doc = preview["manifest"]
|
||||
extension_id, version = doc["id"], doc["version"]
|
||||
state = self._state(extension_id)
|
||||
if state and state.get("remove"):
|
||||
raise ValueError("扩展等待卸载,请先重启主程序")
|
||||
if state and version == state.get("active"):
|
||||
return {"id": extension_id, "message": "此版本已安装"}
|
||||
if state and state.get("active") and Version(version) <= Version(state["active"]):
|
||||
raise ValueError("更新需要更高版本,退回旧版本请使用回退操作")
|
||||
files = read_code_archive(data, frontend=True)
|
||||
target = self._version_dir(extension_id, version)
|
||||
content_hash = _contents_hash(files)
|
||||
if target.exists():
|
||||
if self._read_version_files(extension_id, version) != files:
|
||||
raise ValueError("该版本目录已有其他内容,请提升版本号")
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = Path(tempfile.mkdtemp(prefix=".stage-", dir=target.parent))
|
||||
try:
|
||||
for name, payload in files.items():
|
||||
dest = staging / name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(payload)
|
||||
staging.rename(target)
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
state = state or {"schema": 1, "active": None, "enabled": True, "hashes": {}, "contents": {}}
|
||||
state["hashes"][version] = digest
|
||||
state["contents"][version] = content_hash
|
||||
state["pending"] = version
|
||||
state["updated_at"] = time.time()
|
||||
state.pop("error", None)
|
||||
state.pop("failed_version", None)
|
||||
self._write(extension_id, state)
|
||||
return {"id": extension_id, "message": "扩展包已保存,重启主程序后生效"}
|
||||
|
||||
def _read_version_files(self, extension_id: str, version: str) -> dict[str, bytes]:
|
||||
path = self._version_dir(extension_id, version)
|
||||
files = {}
|
||||
total = 0
|
||||
for item in path.rglob("*"):
|
||||
if item.is_symlink():
|
||||
raise ValueError("扩展文件不能是符号链接")
|
||||
if not item.is_file() or "__pycache__" in item.parts:
|
||||
continue
|
||||
total += item.stat().st_size
|
||||
if total > 80 * 1024 * 1024 or len(files) >= 1000:
|
||||
raise ValueError("扩展文件超限")
|
||||
files[item.relative_to(path).as_posix()] = item.read_bytes()
|
||||
return files
|
||||
|
||||
def _manifest(self, extension_id: str, version: str, state: dict) -> tuple[dict, dict[str, bytes]]:
|
||||
files = self._read_version_files(extension_id, version)
|
||||
if _contents_hash(files) != state.get("contents", {}).get(version):
|
||||
raise ValueError("扩展文件与安装记录不一致")
|
||||
doc = yaml.safe_load(files["extension.yaml"])
|
||||
_validate_manifest(doc, files)
|
||||
if doc["id"] != extension_id or doc["version"] != version:
|
||||
raise ValueError("扩展清单与安装记录不一致")
|
||||
return doc, files
|
||||
|
||||
def manage(self, extension_id: str, action: str, revision: str | None = None) -> dict:
|
||||
with self.mutation():
|
||||
state = self._state(extension_id)
|
||||
if not state:
|
||||
raise ValueError("扩展不存在")
|
||||
if revision is not None and revision != self._revision(state):
|
||||
raise ValueError("扩展状态已变化,请刷新清单后重试")
|
||||
if state.get("remove"):
|
||||
raise ValueError("扩展等待卸载,请先重启主程序")
|
||||
if action in ("enable", "disable"):
|
||||
state["pending_enabled"] = action == "enable"
|
||||
if action == "enable" and not state.get("active") and state.get("failed_version"):
|
||||
state["pending"] = state["failed_version"]
|
||||
elif action == "rollback":
|
||||
if not state.get("previous"):
|
||||
raise ValueError("没有可回退版本")
|
||||
state["pending"] = state["previous"]
|
||||
elif action == "cancel-update":
|
||||
if not state.get("active"):
|
||||
raise ValueError("首次安装尚未生效,请使用卸载操作取消")
|
||||
state.pop("pending", None)
|
||||
state.pop("error", None)
|
||||
elif action == "remove":
|
||||
state["remove"] = True
|
||||
else:
|
||||
raise ValueError("不支持的扩展操作")
|
||||
self._write(extension_id, state)
|
||||
return {"id": extension_id, "message": "变更已保存,重启主程序后生效"}
|
||||
|
||||
def _ids(self) -> list[str]:
|
||||
if self.root.is_symlink():
|
||||
raise ValueError("扩展目录不能是符号链接")
|
||||
if not self.root.exists():
|
||||
return []
|
||||
return sorted(p.name for p in self.root.iterdir() if p.is_dir() and not p.is_symlink() and _ID.fullmatch(p.name))
|
||||
|
||||
def list_installed(self, running: dict[str, str]) -> list[dict]:
|
||||
result = []
|
||||
for extension_id in self._ids():
|
||||
try:
|
||||
state = self._state(extension_id)
|
||||
if not state:
|
||||
continue
|
||||
version = state.get("active") or state.get("pending") or state.get("failed_version")
|
||||
doc, _ = self._manifest(extension_id, version, state)
|
||||
status = "已启用" if extension_id in running else "已停用"
|
||||
if state.get("pending"):
|
||||
status = "待重启生效"
|
||||
if state.get("error"):
|
||||
status = "启动失败 · " + ("保留原版" if extension_id in running else "未启用")
|
||||
if "pending_enabled" in state:
|
||||
status = "待重启" + ("启用" if state["pending_enabled"] else "停用")
|
||||
if state.get("remove"):
|
||||
status = "待重启卸载"
|
||||
result.append({
|
||||
"id": extension_id, "name": doc.get("display_name") or extension_id,
|
||||
"version": state.get("active"), "pending_version": state.get("pending"),
|
||||
"previous_version": state.get("previous"), "enabled": state.get("pending_enabled", state.get("enabled", True)),
|
||||
"runtime_version": running.get(extension_id), "frontend": bool(doc.get("frontend")),
|
||||
"failed_version": state.get("failed_version"), "revision": self._revision(state),
|
||||
"status": status, "error": state.get("error", ""), "pending_remove": bool(state.get("remove")),
|
||||
})
|
||||
except (ValueError, OSError, KeyError, TypeError, yaml.YAMLError):
|
||||
result.append({"id": extension_id, "name": extension_id, "status": "安装记录损坏", "error": "扩展记录或文件损坏,未加载", "enabled": False})
|
||||
return result
|
||||
|
||||
def runtime_pages(self, running: dict[str, str]) -> list[PagePackMeta]:
|
||||
pages = []
|
||||
for extension_id, version in running.items():
|
||||
try:
|
||||
doc, files = self._manifest(extension_id, version, self._state(extension_id))
|
||||
if doc.get("frontend"):
|
||||
payload = files[doc["frontend"]]
|
||||
pages.append(PagePackMeta(
|
||||
id=f"package:{extension_id}", enabled=True, label=doc.get("display_name") or extension_id,
|
||||
size=len(payload), sha256=_hash(payload), managed_by=extension_id, version=version,
|
||||
))
|
||||
except (ValueError, OSError, KeyError, TypeError, yaml.YAMLError):
|
||||
continue
|
||||
return pages
|
||||
|
||||
def runtime_bundle(self, pack_id: str, running: dict[str, str]) -> Path | None:
|
||||
extension_id = pack_id.removeprefix("package:")
|
||||
version = running.get(extension_id)
|
||||
if not pack_id.startswith("package:") or not version:
|
||||
return None
|
||||
doc, _ = self._manifest(extension_id, version, self._state(extension_id))
|
||||
return self._version_dir(extension_id, version) / doc["frontend"] if doc.get("frontend") else None
|
||||
|
||||
|
||||
def _prepare(app: FastAPI, context: ExtensionContext, store: ExtensionPackageStore, extension_id: str, version: str, state: dict):
|
||||
doc, files = store._manifest(extension_id, version, state)
|
||||
issues = compatibility(doc)
|
||||
if issues:
|
||||
raise ValueError(";".join(issues))
|
||||
registry = app.state.extension_registry.mutable_copy()
|
||||
if extension_id in registry.extension_ids():
|
||||
raise ValueError("扩展 ID 与主程序已有扩展冲突")
|
||||
namespace = "_tsp_extension_" + _hash(str(store._version_dir(extension_id, version).resolve()).encode() + _contents_hash(files).encode())[:24]
|
||||
if namespace not in sys.modules:
|
||||
package = types.ModuleType(namespace)
|
||||
package.__path__ = [str(store._version_dir(extension_id, version))]
|
||||
package.__package__ = namespace
|
||||
sys.modules[namespace] = package
|
||||
module = importlib.import_module(f"{namespace}.{doc['backend']}")
|
||||
if getattr(module, "EXTENSION_ID", None) != extension_id or getattr(module, "EXTENSION_API_VERSION", None) != BACKEND_EXTENSION_API_VERSION:
|
||||
raise ValueError("后端模块 ID/契约与清单不一致")
|
||||
setup = getattr(module, "setup", None)
|
||||
if not callable(setup) or inspect.iscoroutinefunction(setup):
|
||||
raise ValueError("扩展需要同步 setup(registrar)")
|
||||
registrar = BackendExtensionRegistrar(extension_id, api_version=BACKEND_EXTENSION_API_VERSION)
|
||||
setup(registrar)
|
||||
prefix = f"/api/extensions/{extension_id}/"
|
||||
for router in registrar.routers:
|
||||
for route in router.routes:
|
||||
if not isinstance(route, APIRoute) or not route.path.startswith(prefix):
|
||||
raise ValueError(f"外部扩展接口必须位于 {prefix}")
|
||||
from app.extensions.loader import _validate_router_conflicts
|
||||
_validate_router_conflicts(app, registrar)
|
||||
registry.register(registrar)
|
||||
startup = getattr(module, "startup", None)
|
||||
if startup is not None:
|
||||
if not callable(startup) or inspect.iscoroutinefunction(startup):
|
||||
raise ValueError("扩展 startup(context) 必须是同步函数")
|
||||
startup(context)
|
||||
registry.freeze()
|
||||
return registrar, registry
|
||||
|
||||
|
||||
def start_managed_extensions(app: FastAPI, context: ExtensionContext) -> None:
|
||||
"""Stage registration and startup before publishing routes, policies and pages."""
|
||||
if getattr(app.state, "managed_extensions_started", False):
|
||||
return
|
||||
store = ExtensionPackageStore(context.data_dir)
|
||||
running: dict[str, str] = {}
|
||||
app.state.managed_extension_versions = running
|
||||
try:
|
||||
ids = store._ids()
|
||||
except ValueError:
|
||||
ids = []
|
||||
for extension_id in ids:
|
||||
try:
|
||||
state = store._state(extension_id)
|
||||
if not state:
|
||||
continue
|
||||
if state.get("remove"):
|
||||
with store.mutation():
|
||||
if store._revision(store._state(extension_id)) != store._revision(state):
|
||||
continue
|
||||
shutil.rmtree(store._directory(extension_id))
|
||||
continue
|
||||
enabled = state.get("pending_enabled", state.get("enabled", True))
|
||||
if not enabled:
|
||||
with store.mutation():
|
||||
if store._revision(store._state(extension_id)) != store._revision(state):
|
||||
continue
|
||||
state["enabled"] = False
|
||||
state.pop("pending_enabled", None)
|
||||
store._write(extension_id, state)
|
||||
continue
|
||||
candidates = list(dict.fromkeys(v for v in (state.get("pending"), state.get("active")) if v))
|
||||
failed = False
|
||||
for version in candidates:
|
||||
routes_before = list(app.router.routes)
|
||||
lifespan_before = app.router.lifespan_context
|
||||
revision = store._revision(state)
|
||||
try:
|
||||
registrar, registry = _prepare(app, context, store, extension_id, version, state)
|
||||
for router in registrar.routers:
|
||||
app.include_router(router)
|
||||
# Lifespan runs after the production SPA catch-all is mounted.
|
||||
# Publish API routes before that fallback so GETs reach Python.
|
||||
fallback = next((i for i, route in enumerate(routes_before)
|
||||
if getattr(route, "name", None) == "spa_fallback"
|
||||
and getattr(route, "path", None) == "/{full_path:path}"), len(routes_before))
|
||||
added = app.router.routes[len(routes_before):]
|
||||
app.router.routes[:] = routes_before[:fallback] + added + routes_before[fallback:]
|
||||
with store.mutation():
|
||||
if store._revision(store._state(extension_id)) != store._revision(state):
|
||||
raise _StateChangedError("扩展状态在启动时发生变化")
|
||||
next_state = dict(state)
|
||||
if version != state.get("active"):
|
||||
next_state["previous"] = state.get("active")
|
||||
next_state.update(active=version, enabled=True)
|
||||
next_state.pop("pending", None)
|
||||
next_state.pop("pending_enabled", None)
|
||||
if not failed and state.get("pending") == version:
|
||||
next_state.pop("error", None)
|
||||
next_state.pop("failed_version", None)
|
||||
store._write(extension_id, next_state)
|
||||
app.state.extension_registry = registry
|
||||
running[extension_id] = version
|
||||
app.openapi_schema = None
|
||||
break
|
||||
except Exception as exc: # noqa: BLE001 — optional code cannot leave partial routes or policies
|
||||
app.router.routes[:] = routes_before
|
||||
app.router.lifespan_context = lifespan_before
|
||||
if isinstance(exc, _StateChangedError):
|
||||
break
|
||||
failed = True
|
||||
state["error"] = "新版本启动失败,已保留原版本" if state.get("active") else "扩展启动失败,未启用"
|
||||
state["failed_version"] = version
|
||||
state.pop("pending", None)
|
||||
with store.mutation():
|
||||
if store._revision(store._state(extension_id)) != revision:
|
||||
break
|
||||
store._write(extension_id, state)
|
||||
except Exception: # noqa: BLE001 — corrupt records must not stop unrelated extensions
|
||||
continue
|
||||
app.state.managed_extensions_started = True
|
||||
@@ -111,6 +111,13 @@ class BackendExtensionRegistry:
|
||||
)
|
||||
self._frozen = True
|
||||
|
||||
def mutable_copy(self) -> BackendExtensionRegistry:
|
||||
"""Stage an optional package without mutating the published snapshot."""
|
||||
registry = BackendExtensionRegistry()
|
||||
registry._extension_ids = self._extension_ids.copy()
|
||||
registry._notification_formatters = self._notification_formatters.copy()
|
||||
return registry
|
||||
|
||||
def notification_formatters(
|
||||
self,
|
||||
) -> tuple[RegisteredImplementation[NotificationFormatter], ...]:
|
||||
|
||||
+8
-1
@@ -26,6 +26,7 @@ from app.api import (
|
||||
factors,
|
||||
financials,
|
||||
guardian,
|
||||
import_center,
|
||||
indices,
|
||||
intraday,
|
||||
kline,
|
||||
@@ -199,6 +200,12 @@ async def _application_lifespan(app: FastAPI):
|
||||
except Exception as e: # noqa: BLE001 — 仅日志, 失败不影响启动
|
||||
logger.warning("能力路由日志输出失败: %s", e)
|
||||
|
||||
# 发布完整冻结快照后再启动行情与监控线程,避免首批事件使用未完成的注册表。
|
||||
from app.extensions.packages import start_managed_extensions
|
||||
start_managed_extensions(
|
||||
app, current_extension_context(data_dir=store.data_dir, repository=repo),
|
||||
)
|
||||
|
||||
# 二次开发升级保护: 后台常备备份用户修改的程序文件 (无清单时自动停用)
|
||||
from app.services.upgrade_guard import UpgradeGuard
|
||||
|
||||
@@ -468,7 +475,6 @@ async def _application_lifespan(app: FastAPI):
|
||||
current_extension_context(data_dir=store.data_dir, repository=repo),
|
||||
extension_registry,
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -674,6 +680,7 @@ app.include_router(strategy.router)
|
||||
app.include_router(signals.router)
|
||||
app.include_router(monitor_rules.router)
|
||||
app.include_router(config_backup.router)
|
||||
app.include_router(import_center.router)
|
||||
app.include_router(lots.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(events.router)
|
||||
|
||||
@@ -30,6 +30,8 @@ class PagePackMeta:
|
||||
uploaded_at: float = 0.0
|
||||
# 前端试载/注册失败的错误信息; 空串表示未出错
|
||||
error: str = ""
|
||||
managed_by: str = ""
|
||||
version: str = ""
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Identify import types, then delegate to their existing validation services."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from app.data_providers.custom import packages as source_packages
|
||||
from app.extensions.packages import ExtensionPackageStore
|
||||
from app.services import config_backup, resource_bundle
|
||||
|
||||
|
||||
def inspect_uploads(uploads: list[tuple[str, bytes]], data_dir: Path) -> dict:
|
||||
if not uploads:
|
||||
raise ValueError("请选择要导入的文件")
|
||||
kinds = []
|
||||
for filename, data in uploads:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
kind = "resources"
|
||||
if suffix in (".js", ".mjs", ".tsx"):
|
||||
kind = "page"
|
||||
elif suffix == ".zip":
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
if len(archive.infolist()) > config_backup.MAX_ENTRIES:
|
||||
raise ValueError("包内文件数量超过上限")
|
||||
names = archive.namelist()
|
||||
if "extension.yaml" in names and "plugin.yaml" in names:
|
||||
raise ValueError("扩展包类型不明确,不能同时包含两种安装清单")
|
||||
if "extension.yaml" in names:
|
||||
kind = "extension"
|
||||
elif "plugin.yaml" in names:
|
||||
kind = "source"
|
||||
elif "manifest.json" in names:
|
||||
info = archive.getinfo("manifest.json")
|
||||
if info.file_size > 64 * 1024:
|
||||
raise ValueError("包清单超过 64 KiB")
|
||||
doc = json.loads(archive.read(info))
|
||||
if isinstance(doc, dict) and doc.get("format") == "tick-config-backup":
|
||||
kind = "backup"
|
||||
except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("无法识别 ZIP 文件或包清单") from exc
|
||||
elif suffix not in (".py", ".json"):
|
||||
raise ValueError("支持 ZIP、策略 .py、定义 .json 和页面 .js/.mjs/.tsx")
|
||||
kinds.append(kind)
|
||||
if len(uploads) > 1 and any(kind != "resources" for kind in kinds):
|
||||
raise ValueError("策略、信号和因子可以多选;备份、数据源和扩展包请逐包导入")
|
||||
kind = kinds[0]
|
||||
filename, data = uploads[0]
|
||||
if kind == "extension":
|
||||
preview = ExtensionPackageStore(data_dir).inspect(data)
|
||||
elif kind == "source":
|
||||
preview = source_packages.inspect_archive(data)[0]
|
||||
elif kind == "backup":
|
||||
preview = config_backup.inspect_archive(data)
|
||||
elif kind == "page":
|
||||
limit = (1 if filename.lower().endswith(".tsx") else 5) * 1024 * 1024
|
||||
if not data or len(data) > limit:
|
||||
raise ValueError(f"页面文件为空或超过 {limit // 1024 // 1024} MiB")
|
||||
data.decode("utf-8")
|
||||
preview = {
|
||||
"filename": filename, "size": len(data), "digest": hashlib.sha256(data).hexdigest(),
|
||||
"source": filename.lower().endswith(".tsx"),
|
||||
"suggested_id": re.sub(r"[^a-zA-Z0-9_]", "_", Path(filename).stem)[:64] or "imported_page",
|
||||
}
|
||||
else:
|
||||
preview = resource_bundle.inspect_resources(uploads)
|
||||
return {"kind": kind, "preview": preview}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Shared static checks for trusted, externally installed source packages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.metadata
|
||||
import io
|
||||
import platform
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
|
||||
from app import __version__
|
||||
|
||||
MAX_UPLOAD = 20 * 1024 * 1024
|
||||
MAX_EXPANDED = 80 * 1024 * 1024
|
||||
SOURCE_SUFFIXES = {".py", ".yaml", ".yml", ".json", ".txt", ".md", ".csv"}
|
||||
|
||||
|
||||
def read_code_archive(data: bytes, *, frontend: bool = False) -> dict[str, bytes]:
|
||||
if len(data) > MAX_UPLOAD:
|
||||
raise ValueError("插件包超过 20 MiB")
|
||||
files: dict[str, bytes] = {}
|
||||
folded: set[str] = set()
|
||||
total = 0
|
||||
suffixes = SOURCE_SUFFIXES | ({".js", ".mjs", ".css", ".svg", ".png", ".jpg", ".webp"} if frontend else set())
|
||||
reserved = {"CON", "PRN", "AUX", "NUL", *[f"COM{i}" for i in range(10)], *[f"LPT{i}" for i in range(10)]}
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
if len(archive.infolist()) > 1000:
|
||||
raise ValueError("插件包条目超过 1000")
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
parts = PurePosixPath(name).parts
|
||||
if (not parts or name.startswith("/") or "\\" in name or ":" in name
|
||||
or any(p in (".", "..") or p.endswith((".", " ")) or p.split(".")[0].upper() in reserved for p in parts)
|
||||
or PurePosixPath(name).as_posix() != name.rstrip("/")):
|
||||
raise ValueError("插件包包含非法路径")
|
||||
if stat.S_IFMT(info.external_attr >> 16) not in (0, stat.S_IFREG, stat.S_IFDIR):
|
||||
raise ValueError("插件包不允许符号链接或特殊文件")
|
||||
key = name.casefold().rstrip("/")
|
||||
if key in folded:
|
||||
raise ValueError("插件包包含重复或大小写冲突的路径")
|
||||
folded.add(key)
|
||||
if info.is_dir():
|
||||
continue
|
||||
total += info.file_size
|
||||
if total > MAX_EXPANDED or info.file_size > MAX_UPLOAD:
|
||||
raise ValueError("插件包解压大小超限")
|
||||
if PurePosixPath(name).suffix.lower() not in suffixes:
|
||||
raise ValueError("只支持源码和页面产物;不接收原生库、wheel、脚本或可执行文件")
|
||||
files[name] = archive.read(info)
|
||||
except (zipfile.BadZipFile, RuntimeError, NotImplementedError) as exc:
|
||||
raise ValueError("无法读取 ZIP 插件包") from exc
|
||||
# Also reject a file being used as another member's parent, on every OS.
|
||||
names = set(files)
|
||||
for name in files:
|
||||
if any(parent.as_posix() in names for parent in PurePosixPath(name).parents):
|
||||
raise ValueError("插件包的文件与目录路径冲突")
|
||||
return files
|
||||
|
||||
|
||||
def compatibility(doc: dict) -> list[str]:
|
||||
errors = []
|
||||
for field, current in (("requires_app", __version__), ("requires_python", platform.python_version())):
|
||||
if doc.get(field) and Version(current) not in SpecifierSet(str(doc[field])):
|
||||
errors.append(f"{field}: 需要 {doc[field]},当前 {current}")
|
||||
platforms = doc.get("platforms", ["any"])
|
||||
current = f"{sys.platform}-{platform.machine().lower()}"
|
||||
if not isinstance(platforms, list) or not all(isinstance(x, str) for x in platforms):
|
||||
raise ValueError("platforms 必须是字符串列表")
|
||||
if "any" not in platforms and current not in platforms:
|
||||
errors.append(f"运行平台不匹配,当前 {current}")
|
||||
requirements = doc.get("requires", [])
|
||||
if not isinstance(requirements, list):
|
||||
raise ValueError("requires 必须是依赖列表")
|
||||
for item in requirements:
|
||||
req = Requirement(item)
|
||||
if req.url:
|
||||
raise ValueError("requires 不接受 URL 或安装脚本")
|
||||
if req.marker and not req.marker.evaluate():
|
||||
continue
|
||||
try:
|
||||
installed = importlib.metadata.version(req.name)
|
||||
if installed not in req.specifier:
|
||||
errors.append(f"依赖 {req.name} 版本不匹配:{installed}")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
errors.append(f"缺少依赖 {req.name}(不会自动修改主程序环境)")
|
||||
return errors
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.extensions.contracts import ExtensionContext
|
||||
from app.extensions.packages import ExtensionPackageStore, start_managed_extensions
|
||||
from app.extensions.registry import BackendExtensionRegistry
|
||||
|
||||
|
||||
def bundle(version="1.0.0", *, broken=False, extra=None, **changes):
|
||||
doc = {
|
||||
"format": "tick-extension-package", "format_version": 1,
|
||||
"id": "demo.research", "display_name": "研究服务", "version": version,
|
||||
"extension_api_version": 1, "backend": "backend.extension",
|
||||
"frontend": "frontend/extension.mjs",
|
||||
}
|
||||
doc.update(changes)
|
||||
code = f'''
|
||||
from fastapi import APIRouter
|
||||
EXTENSION_ID = "demo.research"
|
||||
EXTENSION_API_VERSION = 1
|
||||
def setup(registrar):
|
||||
router = APIRouter(prefix="/api/extensions/demo.research")
|
||||
@router.get("/status")
|
||||
def status():
|
||||
return {{"version": "{version}"}}
|
||||
registrar.include_router(router)
|
||||
def startup(context):
|
||||
{"raise RuntimeError('private-credential-must-not-leak')" if broken else "pass"}
|
||||
'''
|
||||
files = {
|
||||
"extension.yaml": yaml.safe_dump(doc),
|
||||
"backend/extension.py": code,
|
||||
"frontend/extension.mjs": f"export default {{id:'demo.research',apiVersion:1,version:'{version}'}}",
|
||||
}
|
||||
files.update(extra or {})
|
||||
data = io.BytesIO()
|
||||
with zipfile.ZipFile(data, "w") as archive:
|
||||
for name, payload in files.items():
|
||||
archive.writestr(name, payload)
|
||||
return data.getvalue()
|
||||
|
||||
|
||||
def install(store, data):
|
||||
preview = store.inspect(data)
|
||||
return store.install(data, preview["digest"], preview["revision"])
|
||||
|
||||
|
||||
def boot(tmp_path):
|
||||
app = FastAPI()
|
||||
registry = BackendExtensionRegistry()
|
||||
registry.freeze()
|
||||
app.state.extension_registry = registry
|
||||
start_managed_extensions(app, ExtensionContext(1, tmp_path, SimpleNamespace()))
|
||||
return app, TestClient(app)
|
||||
|
||||
|
||||
def test_inspection_and_install_never_execute_code(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
marker = tmp_path / "executed"
|
||||
data = bundle(extra={"backend/evil.py": f"open({str(marker)!r}, 'w').write('bad')"})
|
||||
preview = store.inspect(data)
|
||||
assert not preview["errors"] and preview["manifest"]["frontend"]
|
||||
assert not store.root.exists()
|
||||
store.install(data, preview["digest"], preview["revision"])
|
||||
assert not marker.exists()
|
||||
assert store.list_installed({})[0]["status"] == "待重启生效"
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").json() == {"version": "1.0.0"}
|
||||
assert store.runtime_pages(app.state.managed_extension_versions)[0].enabled
|
||||
|
||||
|
||||
def test_update_is_staged_and_failed_start_preserves_front_and_back(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
first, client = boot(tmp_path)
|
||||
install(store, bundle("2.0.0", broken=True))
|
||||
assert client.get("/api/extensions/demo.research/status").json()["version"] == "1.0.0"
|
||||
before = store.runtime_bundle("package:demo.research", first.state.managed_extension_versions)
|
||||
assert b"1.0.0" in before.read_bytes()
|
||||
failed, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").json()["version"] == "1.0.0"
|
||||
assert failed.state.managed_extension_versions == {"demo.research": "1.0.0"}
|
||||
item = store.list_installed(failed.state.managed_extension_versions)[0]
|
||||
assert "失败" in item["status"] and "保留" in item["error"]
|
||||
assert item["failed_version"] == "2.0.0"
|
||||
assert "private-credential" not in str(item)
|
||||
restarted, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").json()["version"] == "1.0.0"
|
||||
assert store.list_installed(restarted.state.managed_extension_versions)[0]["error"] == item["error"]
|
||||
install(store, bundle("3.0.0"))
|
||||
current, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").json()["version"] == "3.0.0"
|
||||
assert b"3.0.0" in store.runtime_bundle("package:demo.research", current.state.managed_extension_versions).read_bytes()
|
||||
|
||||
|
||||
def test_disable_rollback_remove_are_applied_only_at_restart(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
boot(tmp_path)
|
||||
install(store, bundle("2.0.0"))
|
||||
app, client = boot(tmp_path)
|
||||
store.manage("demo.research", "disable")
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 200
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 404
|
||||
assert store.runtime_pages(app.state.managed_extension_versions) == []
|
||||
store.manage("demo.research", "rollback")
|
||||
boot(tmp_path)
|
||||
assert store.list_installed({})[0]["enabled"] is False
|
||||
store.manage("demo.research", "enable")
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").json()["version"] == "1.0.0"
|
||||
store.manage("demo.research", "remove")
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 200
|
||||
app, client = boot(tmp_path)
|
||||
assert store.list_installed({}) == []
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 404
|
||||
|
||||
|
||||
def test_digest_revision_version_and_compatibility_guards(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
data = bundle()
|
||||
p = store.inspect(data)
|
||||
with pytest.raises(ValueError, match="预览"):
|
||||
store.install(data, "0" * 64, p["revision"])
|
||||
install(store, data)
|
||||
with pytest.raises(ValueError, match="首次安装"):
|
||||
store.manage("demo.research", "cancel-update")
|
||||
store.manage("demo.research", "disable", store._revision(store._state("demo.research")))
|
||||
with pytest.raises(ValueError, match="变化"):
|
||||
store.manage("demo.research", "enable", p["revision"])
|
||||
with pytest.raises(ValueError, match="变化"):
|
||||
store.install(bundle("2.0.0"), store.inspect(bundle("2.0.0"))["digest"], p["revision"])
|
||||
with pytest.raises(ValueError, match="相同版本"):
|
||||
store.inspect(bundle(extra={"readme.md": "changed"}))
|
||||
future = bundle("2.0.0", requires_python=">=99")
|
||||
preview = store.inspect(future)
|
||||
assert preview["errors"]
|
||||
with pytest.raises(ValueError, match="requires_python"):
|
||||
store.install(future, preview["digest"], preview["revision"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["../escape.py", "/tmp/evil.py", "a\\evil.py", "a/../x.py", "CON.py", "x.dll", "backend/extension.py/child.py"])
|
||||
def test_invalid_archive_paths_and_payloads_are_rejected(tmp_path, name):
|
||||
with pytest.raises(ValueError):
|
||||
ExtensionPackageStore(tmp_path).inspect(bundle(extra={name: "pass"}))
|
||||
|
||||
|
||||
def test_symlink_and_corrupt_state_cannot_escape_or_block_other_packages(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
data = io.BytesIO(bundle())
|
||||
with zipfile.ZipFile(data, "a") as archive:
|
||||
info = zipfile.ZipInfo("link.py")
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(info, "../outside.py")
|
||||
with pytest.raises(ValueError):
|
||||
store.inspect(data.getvalue())
|
||||
install(store, bundle())
|
||||
(store.root / "broken.extension").mkdir()
|
||||
(store.root / "broken.extension" / "state.json").write_text("{")
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 200
|
||||
assert len(store.list_installed(app.state.managed_extension_versions)) == 2
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(store.root / "evil.extension").symlink_to(outside, target_is_directory=True)
|
||||
with pytest.raises(ValueError):
|
||||
store.manage("evil.extension", "remove")
|
||||
assert outside.exists()
|
||||
|
||||
|
||||
def test_route_namespace_conflict_and_startup_failures_never_partially_register(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle(extra={
|
||||
"backend/extension.py": '''
|
||||
from fastapi import APIRouter
|
||||
EXTENSION_ID = "demo.research"
|
||||
EXTENSION_API_VERSION = 1
|
||||
def setup(registrar):
|
||||
router = APIRouter()
|
||||
@router.get("/api/settings")
|
||||
def unsafe():
|
||||
return {"hijacked": True}
|
||||
registrar.include_router(router)
|
||||
'''}))
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/settings").status_code == 404
|
||||
assert not app.state.extension_registry.extension_ids()
|
||||
assert not app.state.managed_extension_versions
|
||||
|
||||
|
||||
def test_preview_identifies_the_pending_version_that_will_be_replaced(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
boot(tmp_path)
|
||||
install(store, bundle("2.0.0"))
|
||||
preview = store.inspect(bundle("3.0.0"))
|
||||
assert preview["installed_version"] == "1.0.0"
|
||||
assert preview["pending_version"] == "2.0.0"
|
||||
assert preview["manifest"]["version"] == "3.0.0"
|
||||
|
||||
|
||||
def test_modified_package_and_failed_state_write_do_not_publish_a_new_version(tmp_path, monkeypatch):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
boot(tmp_path)
|
||||
before = store._state("demo.research")
|
||||
monkeypatch.setattr(store, "_write", lambda *_: (_ for _ in ()).throw(PermissionError("无写入权限")))
|
||||
with pytest.raises(PermissionError):
|
||||
install(store, bundle("2.0.0"))
|
||||
assert store._state("demo.research") == before
|
||||
source = store._version_dir("demo.research", "1.0.0") / "backend/extension.py"
|
||||
source.write_text("raise RuntimeError('changed after install')")
|
||||
app, client = boot(tmp_path)
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 404
|
||||
assert not app.state.managed_extension_versions
|
||||
|
||||
|
||||
def test_startup_cannot_overwrite_a_concurrent_management_change(tmp_path, monkeypatch):
|
||||
from app.extensions import packages
|
||||
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
boot(tmp_path)
|
||||
install(store, bundle("2.0.0"))
|
||||
original = packages._prepare
|
||||
|
||||
def prepare(*args):
|
||||
result = original(*args)
|
||||
install(store, bundle("3.0.0"))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(packages, "_prepare", prepare)
|
||||
app, client = boot(tmp_path)
|
||||
assert store._state("demo.research")["pending"] == "3.0.0"
|
||||
assert store._state("demo.research")["active"] == "1.0.0"
|
||||
assert client.get("/api/extensions/demo.research/status").status_code == 404
|
||||
assert not app.state.managed_extension_versions
|
||||
|
||||
|
||||
def test_runtime_frontend_uses_existing_page_distribution_and_management_auth(tmp_path, monkeypatch):
|
||||
from app.api.ext_pages import router as pages_router
|
||||
from app.api.import_center import router
|
||||
from app.services import auth
|
||||
|
||||
monkeypatch.setattr(auth, "is_configured", lambda: False)
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
app, _ = boot(tmp_path)
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
||||
app.include_router(router)
|
||||
app.include_router(pages_router)
|
||||
client = TestClient(app, base_url="http://localhost", client=("127.0.0.1", 12345))
|
||||
pages = client.get("/api/ext-pages").json()
|
||||
assert pages[0]["managed_by"] == "demo.research"
|
||||
assert client.get("/api/ext-pages/package:demo.research/bundle.js").status_code == 200
|
||||
assert client.put("/api/ext-pages/package:demo.research/enabled", json={"enabled": False}).status_code == 400
|
||||
assert client.post("/api/extension-packages/demo.research/manage", json={"action": "disable"}, headers={"Origin": "https://evil.example"}).status_code == 403
|
||||
assert client.post("/api/extension-packages/demo.research/manage", json={"action": "disable"}, headers={"Authorization": "Bearer token"}).status_code == 403
|
||||
data = bundle("2.0.0")
|
||||
preview = store.inspect(data)
|
||||
result = client.post("/api/extension-packages/install", files={"file": ("bundle.zip", data)},
|
||||
data={"digest": preview["digest"], "revision": preview["revision"], "trusted": "false"})
|
||||
assert result.status_code == 403
|
||||
|
||||
|
||||
def test_full_app_registers_managed_packages_before_quote_service_starts(tmp_path):
|
||||
store = ExtensionPackageStore(tmp_path)
|
||||
install(store, bundle())
|
||||
static_dir = tmp_path / "static"
|
||||
static_dir.mkdir()
|
||||
(static_dir / "index.html").write_text("<html>frontend shell</html>")
|
||||
script = """
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app, QuoteService
|
||||
original = QuoteService.boot_check
|
||||
def check_ready(self):
|
||||
assert app.state.extension_registry.frozen
|
||||
assert "demo.research" in app.state.extension_registry.extension_ids()
|
||||
assert app.state.managed_extension_versions == {"demo.research": "1.0.0"}
|
||||
original(self)
|
||||
QuoteService.boot_check = check_ready
|
||||
with TestClient(app, base_url="http://localhost", client=("127.0.0.1", 12345)) as client:
|
||||
assert "frontend shell" in client.get("/").text
|
||||
assert client.get("/api/extensions/demo.research/status").json() == {"version": "1.0.0"}
|
||||
"""
|
||||
env = {**os.environ, "DATA_DIR": str(tmp_path), "STATIC_DIR": str(static_dir), "TICKFLOW_API_KEY": "", "AI_API_KEY": "",
|
||||
"AUTH_PASSWORD": "", "WATCHDOG_ENABLED": "false", "BACKTEST_MATRIX_CACHE_PREWARM": "false"}
|
||||
result = subprocess.run([sys.executable, "-c", script], env=env, capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr[-3000:]
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import import_center
|
||||
from app.config import settings
|
||||
from app.data_providers.custom import loader
|
||||
from app.services import auth, config_backup
|
||||
|
||||
|
||||
def archive(files: dict) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as package:
|
||||
for name, data in files.items():
|
||||
package.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "data_dir", tmp_path)
|
||||
monkeypatch.setattr(auth, "is_configured", lambda: False)
|
||||
monkeypatch.setattr(loader, "plugins_dir", lambda: tmp_path / "builtins")
|
||||
app = FastAPI()
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
||||
app.include_router(import_center.router)
|
||||
return TestClient(app, base_url="http://localhost", client=("127.0.0.1", 12345))
|
||||
|
||||
|
||||
def inspect(client, name, data):
|
||||
return client.post("/api/import-center/inspect", files={"files": (name, data)})
|
||||
|
||||
|
||||
def test_detects_by_package_contents_not_filename_without_writing(client, tmp_path):
|
||||
backup, _ = config_backup.build_backup(categories=["watchlist"])
|
||||
result = inspect(client, "not-named-backup.zip", backup)
|
||||
assert result.status_code == 200 and result.json()["kind"] == "backup"
|
||||
source = archive({
|
||||
"plugin.yaml": yaml.safe_dump({"name": "demo_source", "version": "1.0.0", "plugin_api_version": 1, "entry": "provider:Provider", "datasets": ["realtime"]}),
|
||||
"provider.py": "raise RuntimeError('must not execute')",
|
||||
})
|
||||
assert inspect(client, "backup.zip", source).json()["kind"] == "source"
|
||||
extension = archive({
|
||||
"extension.yaml": yaml.safe_dump({"format": "tick-extension-package", "format_version": 1, "id": "demo.extension", "version": "1.0.0", "extension_api_version": 1, "backend": "extension"}),
|
||||
"extension.py": "raise RuntimeError('must not execute')",
|
||||
})
|
||||
assert inspect(client, "whatever.zip", extension).json()["kind"] == "extension"
|
||||
assert not (tmp_path / "extensions").exists()
|
||||
assert not (tmp_path / "plugins").exists()
|
||||
|
||||
|
||||
def test_resource_collection_and_page_previews(client):
|
||||
factor = json.dumps({"id": "uf_import_center", "label": "测试因子", "kind": "custom", "formula": "close", "lang": "dsl"})
|
||||
result = inspect(client, "factor.json", factor)
|
||||
assert result.status_code == 200, result.text
|
||||
assert result.json()["kind"] == "resources"
|
||||
assert result.json()["preview"]["roots"] == ["factor:uf_import_center"]
|
||||
result = inspect(client, "research-page.mjs", b"throw new Error('not executed')")
|
||||
assert result.json()["kind"] == "page"
|
||||
assert result.json()["preview"]["suggested_id"] == "research_page"
|
||||
assert len(result.json()["preview"]["digest"]) == 64
|
||||
|
||||
|
||||
def test_bad_archives_ambiguous_formats_and_mixed_installations_fail_closed(client):
|
||||
assert inspect(client, "x.zip", b"not zip").status_code == 400
|
||||
assert inspect(client, "x.exe", b"binary").status_code == 400
|
||||
assert inspect(client, "x.zip", archive({"extension.yaml": "{}", "plugin.yaml": "{}"})).status_code == 400
|
||||
result = client.post("/api/import-center/inspect", files=[
|
||||
("files", ("page.mjs", b"export default {}")),
|
||||
("files", ("resource.json", b"{}")),
|
||||
])
|
||||
assert result.status_code == 400 and "逐包" in result.text
|
||||
assert inspect(client, "empty.mjs", b"").status_code == 400
|
||||
|
||||
|
||||
def test_page_apply_requires_same_bytes_trust_and_explicit_overwrite(client, tmp_path):
|
||||
data = b"export default {}"
|
||||
fields = {"id": "test_page", "trusted": "true", "digest": hashlib.sha256(data).hexdigest()}
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", data)}, data={**fields, "trusted": "false"}).status_code == 403
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", b"changed")}, data=fields).status_code == 409
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", data)}, data=fields).status_code == 200
|
||||
target = tmp_path / "ext_pages" / "test_page" / "bundle.js"
|
||||
assert target.read_bytes() == data
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", data)}, data=fields).status_code == 409
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", data)}, data={**fields, "overwrite": "true"}).status_code == 200
|
||||
assert client.post("/api/import-center/page", files={"file": ("page.mjs", data)}, data={**fields, "id": "../escape"}).status_code == 400
|
||||
@@ -137,6 +137,18 @@ def test_same_version_collision_and_preview_digest(isolated):
|
||||
packages.install(bundle("2.0.0"), "0" * 64)
|
||||
|
||||
|
||||
def test_pending_replacement_preview_rejects_stale_install_state(isolated):
|
||||
install(bundle())
|
||||
next_data = bundle("3.0.0")
|
||||
preview, _ = packages.inspect_archive(next_data)
|
||||
install(bundle("2.0.0"))
|
||||
with pytest.raises(ValueError, match="状态已变化"):
|
||||
packages.install(next_data, preview["digest"], preview["revision"])
|
||||
current, _ = packages.inspect_archive(next_data)
|
||||
assert current["installed_version"] == "1.0.0"
|
||||
assert current["pending_version"] == "2.0.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", ["../escape.py", "/escape.py", "a\\b.py", "a/../b.py", "CON.py", "client.PY", "x.dll", "a./b.py"])
|
||||
def test_archive_paths_and_payload_limits(isolated, filename):
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 统一导入与外部后端扩展包
|
||||
|
||||
入口是「设置 → 导入与备份」(`/settings?tab=import`)。策略、因子、信号页的小盒子,以及数据源的「安装插件」都会进入同一页面。旧配置备份和页面插件链接继续重定向到这里。
|
||||
|
||||
## 导入与管理
|
||||
|
||||
选择或拖入文件后,先自动识别、预览,再确认写入:
|
||||
|
||||
- 策略 `.py`、信号/因子 `.json` 和资源 ZIP 可混合多选;复用原有依赖闭包、冲突比较、批次回滚和缓存刷新。
|
||||
- `tick-config-backup` 配置备份按类别恢复。每次新预览都默认不选择密钥和访问凭证;需要迁移 API Key 时明确勾选。
|
||||
- 根目录有 `plugin.yaml` 的数据源包继续使用 [Provider 安装契约](external-data-source-packages.md)。安装后在「已安装扩展」配置 Key、验证并选择数据能力;不会自动切换行情来源。
|
||||
- `.js/.mjs` 页面产物、单个 `.tsx` 或本项目已有源码页面继续使用 [Page Pack SDK](page-sdk.md)。确认信任和同名替换,通过浏览器加载校验后刷新生效。源码编译仍受现有部署环境限制。
|
||||
- 根目录有 `extension.yaml` 的 ZIP 使用下述后端扩展契约,可同时包含配套前端页面。安装仅暂存,重启主程序后生效。
|
||||
|
||||
「创建配置备份」和「批量导出资源」保留原有范围,不包含后台扩展的安装文件。行情数据、K 线和回测缓存不包含在配置备份中。外部数据源与后端扩展请单独保留原 ZIP。
|
||||
|
||||
## 后端 / 前后端完整包
|
||||
|
||||
ZIP 根目录直接放清单,不额外套目录。每包一个后端模块,可选一个自包含页面 ES 模块:
|
||||
|
||||
```text
|
||||
research.zip
|
||||
├── extension.yaml
|
||||
├── backend/
|
||||
│ ├── __init__.py
|
||||
│ └── extension.py
|
||||
└── frontend/
|
||||
└── extension.mjs
|
||||
```
|
||||
|
||||
```yaml
|
||||
format: tick-extension-package
|
||||
format_version: 1
|
||||
id: example.research
|
||||
display_name: 研究观察工作台
|
||||
version: 1.0.0
|
||||
extension_api_version: 1
|
||||
author: 作者声明
|
||||
description: 自定义研究服务与配套页面
|
||||
backend: backend.extension
|
||||
frontend: frontend/extension.mjs # 纯后端扩展省略此项
|
||||
requires_app: '>=0.3.25'
|
||||
requires_python: '>=3.11'
|
||||
platforms: [any]
|
||||
requires: [] # 仅检查已有依赖,不运行 pip/npm
|
||||
```
|
||||
|
||||
`id` 为小写字母开头的字母、数字、下划线,可用点或连字符分段,最长 80 字符;`official.*`、`core.*` 保留。版本如 `1.0.0`;相同版本不同内容拒绝覆盖。更新必须提高版本,降级使用回退操作。
|
||||
|
||||
后端模块沿用 `app.extensions` 的真实契约:
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from app.extensions import BACKEND_EXTENSION_API_VERSION
|
||||
|
||||
EXTENSION_ID = "example.research"
|
||||
EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
|
||||
|
||||
def setup(registrar):
|
||||
router = APIRouter(prefix="/api/extensions/example.research")
|
||||
|
||||
@router.get("/status")
|
||||
def status():
|
||||
return {"ready": True}
|
||||
|
||||
registrar.include_router(router)
|
||||
|
||||
def startup(context):
|
||||
# 可选;核心仓库准备完成后运行。
|
||||
# context 提供 api_version、data_dir、只读 repository 协议。
|
||||
pass
|
||||
```
|
||||
|
||||
`setup` / `startup` 是同步函数。可注册 HTTP 路由和 `NotificationFormatter`,不支持替换核心引擎。外置接口必须位于 `/api/extensions/<包id>/`,不能覆盖核心或其他扩展,不接受 WebSocket 路由。初始化使用 `startup(context)`,不要依赖路由器自身的 lifespan。
|
||||
|
||||
配套页面使用 Page Pack SDK v1,`default.id` 与包 `id` 相同。用户确认信任后,浏览器先加载候选页面检查导出契约,成功才提交安装;预览阶段不执行 JS 或 Python。页面路由最终由宿主注册表检查,启动时发生冲突会显示在「已安装扩展」,需修正包后重新安装。
|
||||
|
||||
## 生效与失败隔离
|
||||
|
||||
安装内容位于 `data/extensions/<id>/versions/<version>/`,状态原子写入 `state.json`。限 20 MiB 压缩包、80 MiB 解压总量、1000 条目;拒绝越界路径、重复/大小写冲突、符号链接、原生库、wheel 和安装脚本。不写入程序源码目录。
|
||||
|
||||
启动时验证文件摘要、兼容性、ID、接口命名空间和注册冲突,再运行启动钩子。成功后才发布路由、通知格式化器和配套页面。新后台失败时保留原版本,新页面不会先切换;单个扩展错误不阻断主程序或其他扩展。
|
||||
|
||||
启停、回退、更新和卸载在下一次主程序启动时应用;浏览器刷新不能替代后台重启。停用中回退不会自动启用。旧页面可能保留在已打开标签中,后台重启完成后需刷新浏览器同步。
|
||||
|
||||
失败隔离覆盖已提交的路由、策略注册与安装状态;可信代码自行产生的文件、网络等副作用无法自动撤销。扩展与主程序同权限,不是沙箱。管理使用面板登录会话,不向开放 API Token 授权;无密码时仅接受本机同源管理。
|
||||
|
||||
首版面向单进程部署;多 worker 应全部停止后统一维护和重启,不提供跨 worker 在线切换。升级后仍需满足包的兼容范围。
|
||||
|
||||
## API
|
||||
|
||||
| 请求 | 行为 |
|
||||
| --- | --- |
|
||||
| `POST /api/import-center/inspect` | 多文件 `files`,只读识别并委托既有预检 |
|
||||
| `GET /api/extension-packages` | 安装、运行、待生效版本与错误 |
|
||||
| `POST /api/extension-packages/install` | `file/digest/revision/trusted=true`,重新校验后暂存 |
|
||||
| `POST /api/extension-packages/frontend` | 信任后读取候选页面供浏览器校验,不发布 |
|
||||
| `POST /api/extension-packages/{id}/manage` | `enable/disable/rollback/cancel-update/remove` |
|
||||
| `POST /api/import-center/page` | 页面上传/编译,增加摘要、信任和同名替换确认 |
|
||||
| `POST /api/import-center/project` | 信任后编译现有项目页面,保留同名确认 |
|
||||
|
||||
配套页面复用 `/api/ext-pages` 清单与分发,标记 `managed_by`,只在对应后台版本就绪时提供。启停和卸载通过整包管理,不能拆开修改。
|
||||
@@ -1,13 +1,13 @@
|
||||
# 外部数据源插件安装
|
||||
|
||||
外部安装是数据源插件机制的新入口:开发者发布 ZIP,用户在「设置 → 数据源 → 安装插件」安装,随后点击新卡片填写 API Key、验证并选择接管的数据能力。无需为每个源改前端或重新发布主程序。
|
||||
外部安装是数据源插件机制的新入口:开发者发布 ZIP,用户在「设置 → 导入与备份」安装(原数据源页「安装插件」会跳转到此),随后点击新卡片填写 API Key、验证并选择接管的数据能力。无需为每个源改前端或重新发布主程序。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 点击数据源区域右上角的小盒子「安装插件」,选择或拖入 ZIP。
|
||||
1. 打开「设置 → 导入与备份」,选择或拖入 ZIP;数据源页的小盒子入口仍可使用。
|
||||
2. 查看名称、作者声明、版本、能力和兼容检查结果。预览不会执行包内 Python。
|
||||
3. 确认信任来源后安装。Python 插件与后端同权限,可读取数据和凭据;这不是沙箱。
|
||||
4. 安装后自动选中新卡片。需要 Key 时显示「待配置 API Key」,有效 Key 才保存到现有 `user_data/secrets.json`;运行时初始化失败恢复原凭据和实例。
|
||||
4. 安装完成后点击「查看已安装扩展」,在数据源详情中继续配置。需要 Key 时显示「待配置 API Key」,有效 Key 才保存到现有 `user_data/secrets.json`;运行时初始化失败恢复原凭据和实例。
|
||||
5. 在详情区「选择启用能力」勾选需要接管的能力,确认当前源到新源的变化。仅修改勾选项;安装、配置 Key 都不会自动切换路由。
|
||||
6. 插件提供 `test_dataset` 时可以逐项试拉;未提供时显示「未检测」。验证 Key 不代表全部接口都有权限,试拉可能消耗供应商额度。
|
||||
|
||||
@@ -74,7 +74,7 @@ requires: [] # 现有宿主发行包版本约束,例如 'htt
|
||||
|
||||
- 数据源加载器负责外置目录发现、包入口解析、统一注册和受控实例释放;原有服务继续通过 `get_provider` / `provider_has_dataset` 取数。
|
||||
- 安装管理通过 `app/custom/source_packages` 的后端扩展注册,不修改主应用路由入口。
|
||||
- 前端管理位于 `src/custom/source-packages`,使用两个正式插槽:`settings.data-sources.toolbar`(`refresh`、`selectSource`)和 `settings.data-sources.detail-actions`(`plugin`、`refresh`)。这些是本次新增的已实现契约。
|
||||
- 统一页面复用 `src/custom/source-packages` 的管理与能力选择组件。数据源页通过两个正式插槽跳转到统一入口:`settings.data-sources.toolbar`(`refresh`、`selectSource`)和 `settings.data-sources.detail-actions`(`plugin`、`refresh`)。这些是本次新增的已实现契约。
|
||||
- 数据源列表、Key、试拉、路由 API 和查询缓存复用原实现,不建立另一套数据服务。
|
||||
|
||||
新增管理端点:
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# 页面插件开发指南 (Page Pack SDK v1)
|
||||
|
||||
页面插件是**运行时可挂载的页面产物**:把页面打包成单文件 ES 模块(`extension.js`),在「设置 → 页面插件」上传,刷新后即成为完整页面——自动注册路由、侧栏入口,可在菜单设置里排序/隐藏。本地 dev、生产构建、桌面端三种运行形态一致生效,宿主无需重新构建。
|
||||
页面插件是**运行时可挂载的页面产物**:把页面打包成单文件 ES 模块(`extension.js`),在「设置 → 导入与备份」上传,刷新后即成为完整页面——自动注册路由、侧栏入口,可在菜单设置里排序/隐藏。本地 dev、生产构建、桌面端三种运行形态一致生效,宿主无需重新构建。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -9,7 +9,7 @@ cp -r frontend/page-pack-template my-pack && cd my-pack
|
||||
pnpm install && pnpm build # → dist/extension.js
|
||||
```
|
||||
|
||||
打开宿主「设置 → 页面插件」→ 填 ID(字母/数字/下划线)→ 选择 `dist/extension.js` → 上传并校验 → 刷新页面。
|
||||
打开宿主「设置 → 导入与备份」→ 选择 `dist/extension.js` → 预览页面标识(字母/数字/下划线)、确认来源及同名替换 → 导入并校验 → 刷新页面。
|
||||
|
||||
## 产物契约 (apiVersion 1)
|
||||
|
||||
@@ -70,3 +70,5 @@ export default extension
|
||||
| 页面插件(运行时产物) | 免重建挂载、独立分发 | 本文档 |
|
||||
| 源码级扩展 `frontend/src/custom/<ns>/extension.tsx` | 随宿主一起构建维护、可用全部核心模块 | `docs/secondary-development.md` §3 |
|
||||
| 声明式分析页面(零代码) | 扩展数据 + 模板化列表/分组页 | 设置 → 扩展页面 |
|
||||
|
||||
配套后台扩展可使用 [前后端完整包](extension-packages.md);单独上传 JS/TSX 只导入前端页面。
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
- 扩展数据与声明式分析页面:适合不需要自定义 React 交互的页面。
|
||||
- 前端源码扩展注册:`frontend/src/custom/<namespace>/extension.tsx`,支持静态页面、导航和已开放插槽。
|
||||
- 后端源码扩展注册:`backend/app/custom/<module>.py`,支持 FastAPI 路由、启动钩子和通知格式化器。
|
||||
- 外部后端扩展包及配套页面安装:`data/extensions/<id>/versions/<version>/`,复用现有注册契约,静态预览、重启生效、失败保留旧版本。入口为「设置 → 导入与备份」,见 [统一导入与外部后端扩展包](extension-packages.md)。
|
||||
- 当前前端插槽:`layout.navigation.extra`、`stock-preview.footer`、`watchlist.toolbar`。
|
||||
- 当前后端继承点:`NotificationFormatter`。
|
||||
- 自定义仓位策略(`PositionSizingPolicy`,已实现):`data/user_data/position_policies/*.py`,在「设置 → 定义 → 仓位策略」创建(表单 META + 只写一个 `def compute(ctx)` 函数,模板起步)。契约见 `app/backtest/position_sizing.py`:ctx 只含当日及以前信息(候选评分/近 60 日收益/已平仓快照/组合状态/参数),输出 weights + budget_scale,引擎侧单仓上限/现金/敞口钳制不变。保存/备份导入均过「AST 安全校验 → 结构解析 → 合成冒烟」三关;回测在「买入权重 → 自定义策略」选用,引用失效 400 拒绝、运行期异常当日回退等权并计入统计。
|
||||
- 因子平台与策略线的既有桥接(已实现,二开时直接复用、勿重复实现):因子库一键生成单因子排名策略(`GenerateFactorStrategyDialog` → `custom_factor_*`);策略触发器引用因子条件信号(`AddFactorSignalDialog`,`csg_f_*`);自定义信号 AI 提示词含因子分组(`custom_signals_ai.py`);策略回测因子归因(`strategy.py` 的 `factor_attribution`,覆盖 `meta.scoring` 非空的策略)。
|
||||
- 旧策略包格式与接口(兼容保留):按依赖闭包打包策略 `.py` + 引用的自定义信号/因子定义 + 用户调参(叠加策略含子策略)。`app/services/strategy_bundle.py` 只保留兼容入口,实际实现统一位于 `app/services/resource_bundle.py`;新旧接口共用依赖闭包、ZIP 安全校验、打包与资源预检,旧导入复用 `config_backup.apply_archive` 的校验/逐条写盘/冒烟管线,缺失依赖在旧预览中只作预警。包内仅允许策略/调参/信号/因子四类,其他路径 400 拒绝。API:`GET /api/strategies/{id}/bundle`、`POST /api/strategies/bundle/inspect|apply`;当前页面入口使用下述统一资源传输。
|
||||
- 统一资源传输(已实现):策略、因子、信号页各使用一个 `ResourceTransferButton` 图标,在当前页打开 `ResourceTransferDialog`;`kind` 限定主资源列表类型,依赖统一展示并自动携带。多文件识别后优先选择同类型原始项,没有此类型原始项时允许从混合包提取该类型定义;确认冲突后只提交所选项及其依赖。`StrategyExportButton` 与弹窗共用 `downloadResourceBundle`,单个策略和批量导出均下载 ZIP,包含所需因子、信号与已保存调参。处理期间禁用关闭,关闭后重开重置临时状态。服务层 `app/services/resource_bundle.py` 统一收集多资源依赖闭包,复用备份路径/原子写与定义编译;新包格式 `tick-resource-bundle` v1,兼容旧策略包。预览通过 `registry.isolated_registry()` 在请求上下文副本内检查同包因子,不向实时线程泄漏定义;新导入链路预检后整批提交,写入/注册/重载失败回滚,区别于旧备份接口的逐条容错语义。API 位于 `/api/definitions/resources`,详见 [策略文档](strategy.md#资源导入与批量导出)。
|
||||
- 统一资源传输(已实现):策略、因子、信号页各使用一个 `ResourceTransferButton` 图标,进入「设置 → 导入与备份」;`kind` 透传资源选择上下文,依赖统一展示并自动携带,批量导出复用 `ResourceTransferDialog`。多文件识别后优先选择同类型原始项,没有此类型原始项时允许从混合包提取该类型定义;确认冲突后只提交所选项及其依赖。`StrategyExportButton` 与弹窗共用 `downloadResourceBundle`,单个策略和批量导出均下载 ZIP,包含所需因子、信号与已保存调参。处理期间禁用关闭,关闭后重开重置临时状态。服务层 `app/services/resource_bundle.py` 统一收集多资源依赖闭包,复用备份路径/原子写与定义编译;新包格式 `tick-resource-bundle` v1,兼容旧策略包。预览通过 `registry.isolated_registry()` 在请求上下文副本内检查同包因子,不向实时线程泄漏定义;新导入链路预检后整批提交,写入/注册/重载失败回滚,区别于旧备份接口的逐条容错语义。API 位于 `/api/definitions/resources`,详见 [策略文档](strategy.md#资源导入与批量导出)。
|
||||
- 模拟盘「绑定即评估」(已实现,`app/strategy/paper_watch.py`):账户绑定策略(自动跟单规则 `match_kind=strategy`)即自动进入实时评估循环——引擎内存态注入合成规则(`paper_watch:{strategy_id}`,不落盘、不进监控页),事件照常喂 `paper_auto.on_rule_events`。同步幂等且与真监控规则去重(同策略已有启用中的监控规则则不注入,避免重复事件);`set_rules` 是全量覆盖,故监控规则任何保存后都会重注入。合成规则的信号是沙盒信号:只驱动模拟盘下单,不进 SSE 告警/系统通知/webhook/留痕(`quote_service` 按 `is_paper_watch_event` 分流)。
|
||||
|
||||
尚未实现、只能在真实需求出现后增加的能力:
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@
|
||||
|
||||
### 资源导入与批量导出
|
||||
|
||||
策略、因子和信号页顶部各有一个**小盒子图标**,在当前页打开对应的导入导出弹窗;策略页入口位于右侧基础配置按钮左边。弹窗主列表只展示当前页的资源类型,关联依赖在右侧预览并自动携带;没有独立资源中心或页面跳转。
|
||||
策略、因子和信号页顶部各有一个**小盒子图标**,进入「设置 → 导入与备份」并保留资源类型上下文;策略页入口位于右侧基础配置按钮左边。统一页展示资源与关联依赖,自动携带所需依赖;批量导出使用同页的资源导出入口。
|
||||
|
||||
单个策略的下载入口位于策略配置对话框顶部,导出的 ZIP 包包含策略源码、已保存调参、相关自定义因子和信号(及其递归依赖);未保存修改须先保存后再导出。页面顶部的小盒子弹窗内可批量导出多项策略、因子或信号,完成后关闭窗口继续使用当前页面。
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
@@ -232,20 +233,11 @@ it('close and Escape cannot interrupt a pending file operation', async () => {
|
||||
expect(mocks.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the single page icon opens the correct dialog and reopening discards the prior selection', async () => {
|
||||
it('the resource entry opens the unified center with its resource context', async () => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
root = createRoot(container)
|
||||
await act(async () => root!.render(<QueryClientProvider client={qc}><ResourceTransferButton kind="factor" /></QueryClientProvider>))
|
||||
const opener = container.querySelector<HTMLButtonElement>('[aria-label="因子导入与导出"]')!
|
||||
expect(container.querySelectorAll('button')).toHaveLength(1)
|
||||
await click(opener)
|
||||
expect(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')).toBe('因子导入与导出')
|
||||
await click(document.querySelector<HTMLElement>('[role="tab"][aria-selected="false"]')!)
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)) })
|
||||
await click(checkbox('选择因子:动量因子'))
|
||||
await click(button('取消'))
|
||||
await act(async () => root!.render(<MemoryRouter><QueryClientProvider client={qc}><ResourceTransferButton kind="factor" /></QueryClientProvider></MemoryRouter>))
|
||||
const opener = container.querySelector<HTMLAnchorElement>('[aria-label="因子导入与导出"]')!
|
||||
expect(opener.getAttribute('href')).toBe('/settings?tab=import&kind=factor')
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
||||
await click(opener)
|
||||
expect(document.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe('导入因子')
|
||||
expect(document.querySelector('[aria-label="选择资源文件"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { refreshResourceQueries } from '@/lib/importCenter'
|
||||
import { Modal } from '@/components/Modal'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, PackageOpen, ArrowLeft, Check, ChevronDown, Download, FileArchive, Link2, Loader2, Search, Upload, X } from 'lucide-react'
|
||||
@@ -15,27 +17,24 @@ const BUTTON = 'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rou
|
||||
const PRIMARY = cn(BUTTON, 'border-blue-600 bg-blue-600 text-white hover:bg-blue-700 hover:text-white')
|
||||
|
||||
export function ResourceTransferButton({ kind, className }: { kind: ResourceKind; className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const label = `${KIND_LABEL[kind]}导入与导出`
|
||||
return <>
|
||||
<button type="button" aria-label={label} title={label} onClick={() => setOpen(true)}
|
||||
className={cn('inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-btn border border-border bg-surface text-secondary transition-colors hover:border-accent/40 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', className)}>
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{open && <ResourceTransferDialog kind={kind} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
const label = KIND_LABEL[kind] + '导入与导出'
|
||||
return <Link to={'/settings?tab=import&kind=' + kind} aria-label={label} title={label}
|
||||
className={cn('inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-btn border border-border bg-surface text-secondary transition-colors hover:border-accent/40 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', className)}>
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
kind: ResourceKind
|
||||
initialMode?: Mode
|
||||
exportOnly?: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 各页共用同一份传输逻辑, 主列表只展示当前页类型, 依赖统一预览。 */
|
||||
export function ResourceTransferDialog({ kind, initialMode = 'import', onClose }: Props) {
|
||||
export function ResourceTransferDialog({ kind, initialMode = 'import', exportOnly = false, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [mode, setMode] = useState<Mode>(initialMode)
|
||||
const [mode, setMode] = useState<Mode>(exportOnly ? 'export' : initialMode)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [overwrite, setOverwrite] = useState<Set<string>>(new Set())
|
||||
@@ -124,10 +123,7 @@ export function ResourceTransferDialog({ kind, initialMode = 'import', onClose }
|
||||
try {
|
||||
const applied = await api.resourcesApply(files, [...selected], conflicts.filter(entry => overwrite.has(entry.key)).map(entry => entry.key), preview.revision)
|
||||
setResult(applied)
|
||||
const keys = [QK.resourceCatalog, QK.definitions, QK.customSignals, QK.customSignalsOptions, QK.factorColumns,
|
||||
QK.factorLibrary('all').slice(0, 1), QK.screenerStrategies().slice(0, 1), QK.strategyDetail('').slice(0, 1),
|
||||
QK.screenerCached().slice(0, 1), QK.screener, QK.monitorRuleOptions, QK.watchlist, QK.strategyLinkOptions().slice(0, 1)]
|
||||
await Promise.allSettled(keys.map(queryKey => qc.invalidateQueries({ queryKey })))
|
||||
await refreshResourceQueries(qc)
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '导入失败,请重新预览') }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
@@ -150,7 +146,7 @@ export function ResourceTransferDialog({ kind, initialMode = 'import', onClose }
|
||||
<button type="button" aria-label="关闭" disabled={busy} onClick={onClose} className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-btn text-muted hover:bg-elevated hover:text-foreground disabled:opacity-40"><X className="h-4 w-4" /></button>
|
||||
</header>
|
||||
<div className="flex shrink-0 gap-5 border-b border-border px-4 sm:px-5" role="tablist" aria-label="资源传输方式">
|
||||
{([['import', `导入${KIND_LABEL[kind]}`], ['export', '批量导出']] as const).map(([value, label]) => (
|
||||
{([['import', `导入${KIND_LABEL[kind]}`], ['export', '批量导出']] as const).filter(([value]) => !exportOnly || value === 'export').map(([value, label]) => (
|
||||
<button key={value} type="button" role="tab" aria-selected={mode === value} disabled={busy} onClick={() => changeMode(value)}
|
||||
className={`border-b-2 py-3 text-xs font-medium transition-colors ${mode === value ? 'border-accent text-accent' : 'border-transparent text-secondary hover:text-foreground'}`}>{label}</button>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
|
||||
@@ -18,7 +19,7 @@ const preview = { manifest: { name: 'demo', display_name: '演示源', version:
|
||||
async function render(element = <InstallDialog onClose={mocks.close} onInstalled={mocks.done} />) {
|
||||
root = createRoot(node)
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
await act(async () => root.render(<QueryClientProvider client={qc}>{element}</QueryClientProvider>))
|
||||
await act(async () => root.render(<MemoryRouter><QueryClientProvider client={qc}>{element}</QueryClientProvider></MemoryRouter>))
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)) })
|
||||
}
|
||||
const button = (label: string) => [...node.querySelectorAll('button')].find(el => el.textContent === label)!
|
||||
@@ -64,12 +65,11 @@ it('blocks escape and closing during installation; failure stays in the dialog',
|
||||
expect(node.querySelector('[role="alert"]')?.textContent).toBe('加载失败')
|
||||
expect(mocks.done).not.toHaveBeenCalled()
|
||||
})
|
||||
it('reopening the installer clears the previous file and preview', async () => {
|
||||
it('the data-source install entry points to the unified import center', async () => {
|
||||
await render(<PackageInstaller refresh={mocks.done} selectSource={vi.fn()} />)
|
||||
await click(button('安装插件')); await choose(); await click(button('取消'))
|
||||
await click(button('安装插件'))
|
||||
expect(node.textContent).not.toContain('静态兼容检查通过')
|
||||
expect(button('确认安装').disabled).toBe(true)
|
||||
const entry = node.querySelector<HTMLAnchorElement>('a')!
|
||||
expect(entry.textContent).toContain('安装插件')
|
||||
expect(entry.getAttribute('href')).toBe('/settings?tab=import&kind=source')
|
||||
})
|
||||
it('only selected routing fields are changed', async () => {
|
||||
await render(<PackageActions plugin={plugin} refresh={mocks.done} />)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Package, X, Upload, Loader2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api, type SourcePackagePreview } from '@/lib/api'
|
||||
@@ -9,20 +10,18 @@ import type { FrontendExtension, FrontendSlotContextMap } from '@/extensions/typ
|
||||
|
||||
const button = 'inline-flex items-center justify-center gap-1.5 rounded-btn border border-border px-3 py-1.5 text-xs hover:bg-elevated disabled:opacity-50'
|
||||
|
||||
export function PackageInstaller({ refresh, selectSource }: FrontendSlotContextMap['settings.data-sources.toolbar']) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
className="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-btn border border-accent/20 bg-accent/10 px-3 text-xs font-medium text-blue-700 transition-colors hover:border-accent/40 hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface dark:text-blue-400"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Package size={14} aria-hidden="true" />
|
||||
安装插件
|
||||
</button>
|
||||
{open && <InstallDialog onClose={() => setOpen(false)} onInstalled={name => { refresh(); selectSource(name); setOpen(false) }} />}
|
||||
</>
|
||||
export function PackageInstaller(_context: FrontendSlotContextMap['settings.data-sources.toolbar']) {
|
||||
return <Link to="/settings?tab=import&kind=source"
|
||||
className="inline-flex min-h-8 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-btn border border-accent/20 bg-accent/10 px-3 text-xs font-medium text-blue-700 hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent dark:text-blue-400">
|
||||
<Package size={14} aria-hidden="true" />安装插件
|
||||
</Link>
|
||||
}
|
||||
|
||||
function PackageManagementLink({ plugin }: FrontendSlotContextMap['settings.data-sources.detail-actions']) {
|
||||
if (plugin.origin !== 'external') return null
|
||||
return <Link to={'/settings?tab=import&view=installed&selected=' + encodeURIComponent('source:' + plugin.name)} className="mt-4 inline-flex min-h-9 items-center gap-2 rounded-btn border border-border px-3 py-2 text-xs text-accent hover:bg-elevated">
|
||||
<Package size={14} aria-hidden="true" />管理插件版本与能力
|
||||
</Link>
|
||||
}
|
||||
|
||||
export function InstallDialog({ onClose, onInstalled }: { onClose: () => void; onInstalled: (name: string) => void }) {
|
||||
@@ -106,7 +105,7 @@ export function PackageActions({ plugin, refresh }: FrontendSlotContextMap['sett
|
||||
{plugin.previous_version && <button disabled={busy} className={button} onClick={() => setConfirm('rollback')}>回退到 {plugin.previous_version}</button>}
|
||||
{plugin.pending_version && <button disabled={busy} className={button} onClick={() => void run('cancel-update')}>取消待生效更新</button>}
|
||||
<button disabled={busy} className={`${button} text-danger`} onClick={() => setConfirm('remove')}>卸载插件</button>
|
||||
<span className="self-center text-muted">更新版本:使用右上角“安装插件”上传新包</span>
|
||||
<span className="self-center text-muted">更新版本:在“导入文件”上传新包</span>
|
||||
</div>}
|
||||
{error && <p role="alert" className="text-danger">{error}</p>}
|
||||
{routing && <RoutingDialog plugin={plugin} onClose={() => setRouting(false)} onSaved={() => { refresh(); setRouting(false) }} />}
|
||||
@@ -166,6 +165,6 @@ export default {
|
||||
id: 'official.source-packages', apiVersion: 1,
|
||||
slots: [
|
||||
{ name: 'settings.data-sources.toolbar', id: 'install', component: PackageInstaller },
|
||||
{ name: 'settings.data-sources.detail-actions', id: 'manage', component: PackageActions },
|
||||
{ name: 'settings.data-sources.detail-actions', id: 'manage', component: PackageManagementLink },
|
||||
],
|
||||
} satisfies FrontendExtension
|
||||
|
||||
@@ -42,6 +42,15 @@ describe('packToExtension: 契约校验与转换', () => {
|
||||
})
|
||||
|
||||
describe('loadRuntimePageExtensions', () => {
|
||||
it('整包页面必须属于已就绪的配套后端,不能注册其他扩展 ID', async () => {
|
||||
const rec = recorder()
|
||||
await loadRuntimePageExtensions({
|
||||
fetchList: async () => [{ id: 'package:demo.backend', managed_by: 'demo.backend', enabled: true }],
|
||||
importModule: async () => ({ default: pack() }),
|
||||
loadInto: rec.loadInto,
|
||||
})
|
||||
await expect(rec.calls[0]['runtime:package:demo.backend']()).rejects.toThrow('配套后端')
|
||||
})
|
||||
it('只加载已启用页面插件, loader 键带 runtime: 前缀且完成契约转换', async () => {
|
||||
const rec = recorder()
|
||||
await loadRuntimePageExtensions({
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface RuntimePageMeta {
|
||||
label?: string
|
||||
path?: string
|
||||
error?: string
|
||||
managed_by?: string
|
||||
}
|
||||
|
||||
/** 产物分发 URL: 同源相对路径, dev(vite 代理)/生产(后端直挂)/桌面端一致。 */
|
||||
@@ -167,7 +168,11 @@ export async function loadRuntimePageExtensions(opts: LoadRuntimeOptions = {}):
|
||||
const url = runtimeBundleUrl(page.id)
|
||||
loaders[`runtime:${page.id}`] = () => {
|
||||
const impl = importModule ? importModule(url) : import(/* @vite-ignore */ url)
|
||||
return impl.then(m => ({ default: packToExtension(m.default) }))
|
||||
return impl.then(m => {
|
||||
const extension = packToExtension(m.default)
|
||||
if (page.managed_by && extension.id !== page.managed_by) throw new Error('页面与配套后端的扩展 ID 不一致')
|
||||
return { default: extension }
|
||||
})
|
||||
}
|
||||
}
|
||||
if (Object.keys(loaders).length === 0) return
|
||||
|
||||
+67
-1
@@ -2089,8 +2089,43 @@ export interface SourcePackagePreview {
|
||||
digest: string
|
||||
errors: string[]
|
||||
installed_version: string | null
|
||||
pending_version?: string | null
|
||||
revision?: string
|
||||
}
|
||||
|
||||
export interface ExtensionPackagePreview {
|
||||
manifest: { id: string; display_name?: string; version: string; author?: string; description?: string; backend: string; frontend?: string }
|
||||
digest: string
|
||||
revision: string
|
||||
errors: string[]
|
||||
files: string[]
|
||||
installed_version: string | null
|
||||
pending_version?: string | null
|
||||
}
|
||||
|
||||
export interface ManagedExtensionPackage {
|
||||
id: string
|
||||
name: string
|
||||
version?: string | null
|
||||
pending_version?: string | null
|
||||
previous_version?: string | null
|
||||
runtime_version?: string | null
|
||||
enabled: boolean
|
||||
frontend?: boolean
|
||||
status: string
|
||||
error: string
|
||||
pending_remove?: boolean
|
||||
failed_version?: string | null
|
||||
revision?: string
|
||||
}
|
||||
|
||||
export type ImportCenterPreview =
|
||||
| { kind: 'resources'; preview: ResourceTransferPreview }
|
||||
| { kind: 'backup'; preview: ConfigBackupInspect }
|
||||
| { kind: 'source'; preview: SourcePackagePreview }
|
||||
| { kind: 'extension'; preview: ExtensionPackagePreview }
|
||||
| { kind: 'page'; preview: { filename: string; size: number; digest: string; source: boolean; suggested_id: string } }
|
||||
|
||||
export interface PluginDataSourceItem {
|
||||
can_test?: boolean
|
||||
origin?: 'builtin' | 'external'
|
||||
@@ -2536,6 +2571,8 @@ export interface RuntimePagePack {
|
||||
sha256: string
|
||||
uploaded_at: number
|
||||
error: string
|
||||
managed_by?: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
// ===== 升级保护 (二次开发) =====
|
||||
@@ -2774,15 +2811,44 @@ export const api = {
|
||||
}),
|
||||
|
||||
preferences: () => request<Preferences>('/api/settings/preferences'),
|
||||
importCenterInspect: (files: File[]) => {
|
||||
const body = new FormData()
|
||||
files.forEach(file => body.append('files', file))
|
||||
return request<ImportCenterPreview>('/api/import-center/inspect', { method: 'POST', body, quiet: true, timeoutMs: 120_000 })
|
||||
},
|
||||
extensionPackages: () => request<ManagedExtensionPackage[]>('/api/extension-packages'),
|
||||
installExtensionPackage: (file: File, digest: string, revision: string) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file); body.append('digest', digest); body.append('revision', revision); body.append('trusted', 'true')
|
||||
return request<{ id: string; message: string }>('/api/extension-packages/install', { method: 'POST', body, quiet: true })
|
||||
},
|
||||
extensionPackageFrontend: (file: File, digest: string) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file); body.append('digest', digest); body.append('trusted', 'true')
|
||||
return request<Blob>('/api/extension-packages/frontend', { method: 'POST', body, responseType: 'blob', quiet: true })
|
||||
},
|
||||
manageExtensionPackage: (id: string, action: string, revision?: string) =>
|
||||
request<{ id: string; message: string }>('/api/extension-packages/' + encodeURIComponent(id) + '/manage', {
|
||||
method: 'POST', body: JSON.stringify({ action, revision }), quiet: true,
|
||||
}),
|
||||
importCenterInstallPage: (file: File, id: string, digest: string, overwrite: boolean) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file); body.append('id', id); body.append('digest', digest)
|
||||
body.append('trusted', 'true'); body.append('overwrite', String(overwrite))
|
||||
return request<RuntimePagePack>('/api/import-center/page', { method: 'POST', body, quiet: true, timeoutMs: COMPUTE_REQUEST_TIMEOUT_MS })
|
||||
},
|
||||
importCenterProjectPage: (namespace: string, id: string, overwrite: boolean) =>
|
||||
request<RuntimePagePack>('/api/import-center/project', { method: 'POST', body: JSON.stringify({ namespace, id, overwrite, trusted: true }), quiet: true, timeoutMs: COMPUTE_REQUEST_TIMEOUT_MS }),
|
||||
inspectSourcePackage: (file: File) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
return request<SourcePackagePreview>('/api/custom/source-packages/inspect', { method: 'POST', body, quiet: true })
|
||||
},
|
||||
installSourcePackage: (file: File, digest: string) => {
|
||||
installSourcePackage: (file: File, digest: string, revision?: string) => {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('digest', digest)
|
||||
if (revision) body.append('revision', revision)
|
||||
return request<{ name: string; message: string }>('/api/custom/source-packages/install', { method: 'POST', body, quiet: true, timeoutMs: 120_000 })
|
||||
},
|
||||
manageSourcePackage: (name: string, action: string) =>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { packToExtension } from '@/extensions/runtime'
|
||||
import { QK } from './queryKeys'
|
||||
|
||||
export const RESOURCE_INVALIDATE_KEYS = [
|
||||
QK.resourceCatalog, QK.definitions, QK.customSignals, QK.customSignalsOptions, QK.factorColumns,
|
||||
QK.factorLibrary('all').slice(0, 1), QK.screenerStrategies().slice(0, 1), QK.strategyDetail('').slice(0, 1),
|
||||
QK.screenerCached().slice(0, 1), QK.screener, QK.monitorRuleOptions, QK.watchlist, QK.strategyLinkOptions().slice(0, 1),
|
||||
]
|
||||
|
||||
export async function refreshResourceQueries(qc: QueryClient) {
|
||||
await Promise.allSettled(RESOURCE_INVALIDATE_KEYS.map(queryKey => qc.invalidateQueries({ queryKey })))
|
||||
}
|
||||
|
||||
export async function inspectPageModule(url: string, expectedId?: string) {
|
||||
const module = await import(/* @vite-ignore */ url)
|
||||
const extension = packToExtension(module.default)
|
||||
if (expectedId && extension.id !== expectedId) throw new Error('页面与配套后端的扩展 ID 不一致')
|
||||
return { label: extension.navigation?.[0]?.label ?? extension.id, path: extension.routes?.[0]?.path ?? '' }
|
||||
}
|
||||
|
||||
export async function inspectPageBlob(blob: Blob, expectedId?: string) {
|
||||
const url = URL.createObjectURL(new Blob([blob], { type: 'text/javascript' }))
|
||||
try { return await inspectPageModule(url, expectedId) }
|
||||
finally { URL.revokeObjectURL(url) }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const MINUTE_DATA_QUERY_PREFIXES = new Set([
|
||||
])
|
||||
|
||||
export const QK = {
|
||||
extensionPackages: ['extension-packages'] as const,
|
||||
// 全局 / 共享 (Layout 预取)
|
||||
capabilities: ['capabilities'] as const,
|
||||
settings: ['settings'] as const,
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
import { Suspense, lazy, useEffect, useState, type ComponentType } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Database, KeyRound, LayoutGrid, Library, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info, HardDriveDownload } from 'lucide-react'
|
||||
import { Database, KeyRound, LayoutGrid, Library, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info, PackageOpen } from 'lucide-react'
|
||||
// 面板按 tab 按需加载: 8 个面板源码 270KB+, 同步打包会让设置页 chunk 膨胀
|
||||
const SettingsAIPanel = lazy(() => import('./settings/AI').then(m => ({ default: m.SettingsAIPanel })))
|
||||
const SettingsApiTokensPanel = lazy(() => import('./settings/ApiTokens').then(m => ({ default: m.SettingsApiTokensPanel })))
|
||||
const SettingsBackupPanel = lazy(() => import('./settings/Backup').then(m => ({ default: m.SettingsBackupPanel })))
|
||||
const SettingsImportCenterPanel = lazy(() => import('./settings/ImportCenter').then(m => ({ default: m.SettingsImportCenterPanel })))
|
||||
const SettingsDefinitionsPanel = lazy(() => import('./settings/Definitions').then(m => ({ default: m.SettingsDefinitionsPanel })))
|
||||
const SettingsMonitoringPanel = lazy(() => import('./settings/Monitoring').then(m => ({ default: m.SettingsMonitoringPanel })))
|
||||
const SettingsPageAdminPanel = lazy(() => import('./settings/PageAdmin').then(m => ({ default: m.SettingsPageAdminPanel })))
|
||||
@@ -40,7 +40,7 @@ const TABS: readonly TabDef[] = [
|
||||
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||
{ key: 'api-tokens', label: '开放接口', icon: KeyRound, panel: SettingsApiTokensPanel },
|
||||
{ key: 'definitions', label: '定义目录', icon: Library, panel: SettingsDefinitionsPanel },
|
||||
{ key: 'backup', label: '配置备份', icon: HardDriveDownload, panel: SettingsBackupPanel },
|
||||
{ key: 'import', label: '导入与备份', icon: PackageOpen, panel: SettingsImportCenterPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
{ key: 'about', label: '关于', icon: Info, panel: SettingsAboutPanel },
|
||||
]
|
||||
@@ -49,7 +49,6 @@ const TABS: readonly TabDef[] = [
|
||||
const LEGACY_PAGE_TAB_SECTIONS: Record<string, string> = {
|
||||
menus: 'menus',
|
||||
'ext-pages': 'ext-pages',
|
||||
'page-packs': 'page-packs',
|
||||
}
|
||||
|
||||
type TabKey = (typeof TABS)[number]['key']
|
||||
@@ -60,16 +59,25 @@ export function Settings() {
|
||||
// 旧 tab (menus/ext-pages/page-packs) 已并入「页面管理」: 渲染按 pages 处理,
|
||||
// 再用 replace 把 URL 归一化并透传 section, 保持深链与历史记录干净
|
||||
const legacySection = tabParam ? LEGACY_PAGE_TAB_SECTIONS[tabParam] : undefined
|
||||
const activeTab = TABS.find((t) => t.key === (legacySection ? 'pages' : tabParam)) ?? TABS[0]
|
||||
const legacyImport = tabParam === 'backup' || tabParam === 'page-packs' || (tabParam === 'pages' && searchParams.get('section') === 'page-packs')
|
||||
const activeTab = TABS.find((t) => t.key === (legacyImport ? 'import' : legacySection ? 'pages' : tabParam)) ?? TABS[0]
|
||||
const highlight = searchParams.get('highlight') ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (legacyImport) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('tab', 'import')
|
||||
next.set('view', tabParam === 'backup' ? 'backup' : 'installed')
|
||||
next.delete('section')
|
||||
setSearchParams(next, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!legacySection) return
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('tab', 'pages')
|
||||
next.set('section', legacySection)
|
||||
setSearchParams(next, { replace: true })
|
||||
}, [legacySection, searchParams, setSearchParams])
|
||||
}, [legacySection, legacyImport, tabParam, searchParams, setSearchParams])
|
||||
|
||||
// 设置菜单收起状态 — 持久化到 localStorage
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
@@ -88,12 +96,19 @@ export function Settings() {
|
||||
<PageHeader
|
||||
title="设置"
|
||||
subtitle="管理账户、数据刷新策略和高级功能配置。"
|
||||
className="pl-14 md:pl-5"
|
||||
/>
|
||||
|
||||
<div className="px-8 py-6">
|
||||
<div className="flex gap-6 items-stretch">
|
||||
<div className="px-4 py-5 sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-stretch">
|
||||
<label className="flex items-center gap-3 text-xs text-secondary md:hidden">
|
||||
设置分类
|
||||
<select aria-label="设置分类" value={activeTab.key} onChange={e => setSearchParams({ tab: e.target.value }, { replace: true })} className="min-h-11 min-w-0 flex-1 rounded-input border border-border bg-surface px-3 text-sm text-foreground">
|
||||
{TABS.map(tab => <option key={tab.key} value={tab.key}>{tab.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{/* ===== 竖向 Tab 侧栏 ===== */}
|
||||
<nav className={cn('shrink-0 transition-all duration-200 ease-smooth', collapsed ? 'w-10' : 'w-36')}>
|
||||
<nav aria-label="设置分类" className={cn('hidden shrink-0 transition-all duration-200 ease-smooth md:block', collapsed ? 'w-10' : 'w-36')}>
|
||||
<div className="flex flex-col gap-0.5 justify-center min-h-[60vh] sticky top-6">
|
||||
{/* 收起/展开 按钮 */}
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Modal } from '@/components/Modal'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
/** 类别选项 — key 与后端 config_backup._CATEGORY_LABELS 一一对应 */
|
||||
const CATEGORY_OPTIONS: { key: string; label: string; desc: string; sensitive?: boolean }[] = [
|
||||
export const CATEGORY_OPTIONS: { key: string; label: string; desc: string; sensitive?: boolean }[] = [
|
||||
{ key: 'strategies', label: '策略源码', desc: '自定义 / AI / 组合策略' },
|
||||
{ key: 'strategy_overrides', label: '策略调参', desc: '参数覆盖' },
|
||||
{ key: 'custom_signals', label: '自定义信号', desc: '信号库中的自定义信号' },
|
||||
@@ -27,7 +27,7 @@ const CATEGORY_OPTIONS: { key: string; label: string; desc: string; sensitive?:
|
||||
{ key: 'research_candidates', label: '挖掘候选', desc: '挖掘入选清单' },
|
||||
{ key: 'data_sources', label: '自定义数据源', desc: '连接配置;API Key 需另选“密钥”' },
|
||||
{ key: 'analysis_menus', label: '分析菜单', desc: '自定义分析入口' },
|
||||
{ key: 'ext_pages', label: '扩展页面', desc: '自定义页面插件 (含可执行代码)' },
|
||||
{ key: 'ext_pages', label: '独立页面插件', desc: '单独导入的页面产物;不含完整包的配套页面' },
|
||||
{ key: 'ext_data', label: '扩展数据集配置', desc: '不含数据本体与 Key' },
|
||||
{ key: 'sound_packs', label: '语音包', desc: '自定义提示音' },
|
||||
{ key: 'history', label: '历史记录', desc: '告警历史与助手会话' },
|
||||
@@ -39,10 +39,10 @@ const CATEGORY_OPTIONS: { key: string; label: string; desc: string; sensitive?:
|
||||
/** 导出默认勾选: 除密钥外全选 (安全默认)。 */
|
||||
const DEFAULT_EXPORT_KEYS = CATEGORY_OPTIONS.filter((c) => !c.sensitive).map((c) => c.key)
|
||||
|
||||
const SCOPE_EXCLUDED = '行情数据、K 线缓存、回测矩阵、任务记录等可重新同步或派生的运行数据不在备份范围内'
|
||||
const SCOPE_EXCLUDED = '行情数据、K 线缓存、回测矩阵和任务记录等运行数据不在备份范围内。外部数据源安装包、后端扩展包及完整包的配套页面也不包含,请另存原始 ZIP;换机时先重新安装扩展,再恢复配置和密钥。'
|
||||
|
||||
/** 导入成功后需要失效的查询 — 与备份类别覆盖的领域对应。 */
|
||||
const INVALIDATE_KEYS: readonly (readonly unknown[])[] = [
|
||||
export const INVALIDATE_KEYS: readonly (readonly unknown[])[] = [
|
||||
QK.preferences,
|
||||
QK.settings,
|
||||
QK.monitorRules,
|
||||
@@ -124,7 +124,7 @@ function CategoryCheck({ label, desc, checked, onChange, sensitive }: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsBackupPanel() {
|
||||
export function SettingsBackupPanel({ mode = 'all' }: { mode?: 'all' | 'export' | 'import' } = {}) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
// ── 导出 ──
|
||||
@@ -146,8 +146,7 @@ export function SettingsBackupPanel() {
|
||||
mutationFn: (f: File) => api.configBackupInspect(f),
|
||||
onSuccess: (r) => {
|
||||
setInspect(r)
|
||||
// 新备份默认全选 (含密钥 — 导出方已决定打包它)
|
||||
setImportSel(new Set(r.categories.map((c) => c.key)))
|
||||
setImportSel(new Set(r.categories.filter(c => c.key !== 'secrets' && c.key !== 'auth').map(c => c.key)))
|
||||
setApplyResult(null)
|
||||
},
|
||||
onError: () => setFile(null), // 错误提示由 request 层统一 toast
|
||||
@@ -191,11 +190,11 @@ export function SettingsBackupPanel() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* ── 导出 ── */}
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
{mode !== 'import' && <div className="rounded-card border border-border bg-surface p-4">
|
||||
<CardHeader
|
||||
icon={<Download className="h-4 w-4" />}
|
||||
title="导出配置备份"
|
||||
subtitle="打包为单个 ZIP 文件, 拷贝到新设备后即可导入恢复"
|
||||
subtitle="打包所选用户配置;外部扩展安装文件需单独保留"
|
||||
>
|
||||
<a
|
||||
href={exportSel.size > 0 ? api.configBackupExportUrl([...exportSel]) : undefined}
|
||||
@@ -283,10 +282,10 @@ export function SettingsBackupPanel() {
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2.5 px-0.5 text-[11px] text-muted">{SCOPE_EXCLUDED}</div>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* ── 导入 ── */}
|
||||
<div className="rounded-card border border-border bg-surface p-4">
|
||||
{mode !== 'export' && <div className="rounded-card border border-border bg-surface p-4">
|
||||
<CardHeader
|
||||
icon={<Upload className="h-4 w-4" />}
|
||||
title="导入配置备份"
|
||||
@@ -529,7 +528,7 @@ export function SettingsBackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* 二次确认 */}
|
||||
{confirming && inspect && (
|
||||
|
||||
@@ -373,7 +373,7 @@ function CapabilityRoutingSection() {
|
||||
|
||||
/** 插件 API Key 配置区块 (嵌入插件详情卡, 不再独立成卡)。
|
||||
* 先探后存: 后端用候选 Key 实探一次, 无效不落盘; secrets.json 优先于 .env。 */
|
||||
function PluginKeyConfig({ plugin }: { plugin: PluginDataSourceItem }) {
|
||||
export function PluginKeyConfig({ plugin }: { plugin: PluginDataSourceItem }) {
|
||||
const qc = useQueryClient()
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [revealing, setRevealing] = useState(false)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { SettingsImportCenterPanel } from './ImportCenter'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inspect: vi.fn(), install: vi.fn(), backup: vi.fn(), resources: vi.fn(), source: vi.fn(),
|
||||
packages: vi.fn(), pages: vi.fn(), sources: vi.fn(), namespaces: vi.fn(), matrix: vi.fn(),
|
||||
manage: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/api', () => ({ api: {
|
||||
importCenterInspect: mocks.inspect, installExtensionPackage: mocks.install,
|
||||
configBackupApply: mocks.backup, resourcesApply: mocks.resources, installSourcePackage: mocks.source,
|
||||
extensionPackages: mocks.packages, extPagesList: mocks.pages, dataSources: mocks.sources,
|
||||
extPagesSourceNamespaces: mocks.namespaces,
|
||||
capabilityMatrix: mocks.matrix,
|
||||
manageExtensionPackage: mocks.manage,
|
||||
} }))
|
||||
vi.mock('@/custom/source-packages/extension', () => ({ PackageActions: () => null }))
|
||||
vi.mock('./DataSources', () => ({ PluginKeyConfig: () => null }))
|
||||
vi.mock('@/extensions/registry', () => ({ getFrontendExtensionLoadErrors: () => [] }))
|
||||
|
||||
const extension = {
|
||||
kind: 'extension',
|
||||
preview: {
|
||||
manifest: { id: 'demo.backend', display_name: '研究服务', version: '1.0.0', backend: 'backend.extension', author: '作者声明' },
|
||||
digest: 'digest', revision: 'revision', files: ['extension.yaml', 'backend/extension.py'], errors: [], installed_version: null,
|
||||
},
|
||||
}
|
||||
const backup = { kind: 'backup', preview: { created_at: '2026-11-01', app_version: '0.3.25', has_secrets: true, total_files: 3, conflict_files: 0, out_of_scope_files: 0,
|
||||
categories: [{ key: 'watchlist', label: '自选股', count: 1, conflicts: 0 }, { key: 'secrets', label: '密钥', count: 1, conflicts: 0 }, { key: 'auth', label: '访问凭证', count: 1, conflicts: 0 }] } }
|
||||
let root: Root
|
||||
let qc: QueryClient
|
||||
const node = document.createElement('div')
|
||||
document.body.appendChild(node)
|
||||
const button = (text: string) => [...node.querySelectorAll('button')].find(el => el.textContent?.trim() === text)!
|
||||
async function click(el: HTMLElement) { expect(el).toBeTruthy(); await act(async () => el.click()) }
|
||||
async function render(url = '/settings?tab=import') {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
root = createRoot(node)
|
||||
await act(async () => root.render(<MemoryRouter initialEntries={[url]}><QueryClientProvider client={qc}><SettingsImportCenterPanel /></QueryClientProvider></MemoryRouter>))
|
||||
}
|
||||
async function pick(filename = 'extension.zip') {
|
||||
const input = node.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', { configurable: true, value: [new File(['data'], filename)] })
|
||||
await act(async () => input.dispatchEvent(new Event('change', { bubbles: true })))
|
||||
}
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
Object.values(mocks).forEach(fn => fn.mockReset())
|
||||
mocks.inspect.mockResolvedValue(extension)
|
||||
mocks.install.mockResolvedValue({ id: 'demo.backend', message: '扩展包已保存,重启主程序后生效' })
|
||||
mocks.backup.mockResolvedValue({ categories: [{ key: 'watchlist', label: '自选股', imported: 1, skipped_conflict: 0, failed: [] }], skipped_unselected: 2, out_of_scope_files: 0 })
|
||||
mocks.packages.mockResolvedValue([])
|
||||
mocks.pages.mockResolvedValue([])
|
||||
mocks.sources.mockResolvedValue({ plugins: [] })
|
||||
mocks.namespaces.mockResolvedValue([])
|
||||
mocks.matrix.mockResolvedValue({ capabilities: [] })
|
||||
mocks.manage.mockResolvedValue({ id: 'demo.backend', message: '变更已保存,重启后生效' })
|
||||
})
|
||||
afterEach(() => { act(() => root?.unmount()); qc?.clear(); node.innerHTML = '' })
|
||||
|
||||
it('one file entry only previews; backend installation requires explicit trust and retains the digest/revision', async () => {
|
||||
await render(); await pick()
|
||||
expect(mocks.install).not.toHaveBeenCalled()
|
||||
expect(button('确认安装').disabled).toBe(true)
|
||||
await click(node.querySelector<HTMLInputElement>('[aria-label="信任代码来源"]')!)
|
||||
await click(button('确认安装'))
|
||||
expect(mocks.install).toHaveBeenCalledWith(expect.any(File), 'digest', 'revision')
|
||||
expect(node.textContent).toContain('重启主程序后生效')
|
||||
})
|
||||
|
||||
it('compatibility errors block a trusted package and a failed request preserves its review', async () => {
|
||||
mocks.inspect.mockResolvedValue({ ...extension, preview: { ...extension.preview, errors: ['需要更高主程序版本'] } })
|
||||
await render(); await pick(); await click(node.querySelector<HTMLInputElement>('[aria-label="信任代码来源"]')!)
|
||||
expect(button('确认安装').disabled).toBe(true)
|
||||
expect(node.textContent).toContain('需要更高主程序版本')
|
||||
})
|
||||
|
||||
it('each backup operation starts without keys/passwords and applies only explicitly selected categories', async () => {
|
||||
mocks.inspect.mockResolvedValue(backup)
|
||||
await render(); await pick('backup.zip')
|
||||
const secret = () => node.querySelector<HTMLInputElement>('[aria-label="恢复密钥"]')!
|
||||
const auth = () => node.querySelector<HTMLInputElement>('[aria-label="恢复访问凭证"]')!
|
||||
expect(secret().checked).toBe(false); expect(auth().checked).toBe(false)
|
||||
await click(secret()); await click(auth())
|
||||
await click(button('重新选择文件')); await pick('other-backup.zip')
|
||||
expect(secret().checked).toBe(false); expect(auth().checked).toBe(false)
|
||||
await click(node.querySelector<HTMLInputElement>('[aria-label="信任备份来源"]')!)
|
||||
await click(button('恢复所选配置'))
|
||||
expect(mocks.backup).toHaveBeenCalledWith(expect.any(File), false, ['watchlist'])
|
||||
})
|
||||
|
||||
it('strategy code needs trust and every selected dependency conflict needs its own replacement confirmation', async () => {
|
||||
mocks.inspect.mockResolvedValue({ kind: 'resources', preview: { roots: ['strategy:s'], revision: 'r', counts: { strategy: 1, signal: 1, factor: 0 }, entries: [
|
||||
{ key: 'strategy:s', kind: 'strategy', id: 's', label: '策略', action: 'create', dependencies: ['signal:x'], issues: [], local_dependencies: [], path: 's.py' },
|
||||
{ key: 'signal:x', kind: 'signal', id: 'x', label: '信号', action: 'conflict', dependencies: [], issues: [], local_dependencies: [], path: 'x.json' },
|
||||
] } })
|
||||
mocks.resources.mockResolvedValue({ imported: 2, reused: 0, counts: { strategy: 1, signal: 1 }, drafts: [] })
|
||||
await render(); await pick('strategy.zip')
|
||||
await click(node.querySelector<HTMLInputElement>('[aria-label="信任代码来源"]')!)
|
||||
expect(button('确认导入').disabled).toBe(true)
|
||||
await click(node.querySelector<HTMLInputElement>('[aria-label="确认替换信号"]')!)
|
||||
await click(button('确认导入'))
|
||||
expect(mocks.resources).toHaveBeenCalledWith(expect.any(Array), ['strategy:s'], ['signal:x'], 'r')
|
||||
})
|
||||
|
||||
it('install failure leaves the file and review available for retry', async () => {
|
||||
mocks.install.mockRejectedValue(new Error('权限不足,请检查目录'))
|
||||
await render(); await pick(); await click(node.querySelector<HTMLInputElement>('[aria-label="信任代码来源"]')!)
|
||||
await click(button('确认安装'))
|
||||
expect(node.querySelector('[role="alert"]')?.textContent).toContain('权限不足')
|
||||
expect(button('确认安装').disabled).toBe(false)
|
||||
expect(node.textContent).toContain('研究服务')
|
||||
})
|
||||
|
||||
it('update review identifies both the active version and the staged version to replace', async () => {
|
||||
mocks.inspect.mockResolvedValue({ ...extension, preview: { ...extension.preview, installed_version: '0.8.0', pending_version: '0.9.0' } })
|
||||
await render(); await pick()
|
||||
expect(node.textContent).toContain('本机 0.8.0 → 目标 1.0.0')
|
||||
expect(node.textContent).toContain('已有待生效版本 0.9.0')
|
||||
expect(node.textContent).toContain('用 1.0.0 替换该暂存版本')
|
||||
})
|
||||
|
||||
it('failed version and rollback target are explicit; management uses the displayed state revision', async () => {
|
||||
mocks.packages.mockResolvedValue([{ id: 'demo.backend', name: '研究服务', version: '2.0.0', previous_version: '1.0.0', runtime_version: '2.0.0',
|
||||
failed_version: '3.0.0', enabled: true, status: '启动失败 · 保留原版', error: '失败', revision: 'state-2' }])
|
||||
await render('/settings?tab=import&view=installed&selected=extension%3Ademo.backend')
|
||||
await vi.waitFor(async () => {
|
||||
await act(async () => {})
|
||||
expect(node.textContent).toContain('版本 3.0.0 启动失败;当前仍运行 2.0.0')
|
||||
})
|
||||
await click(button('回退到 1.0.0'))
|
||||
expect(node.textContent).toContain('确认回退:2.0.0 → 1.0.0')
|
||||
await click(button('确认'))
|
||||
expect(mocks.manage).toHaveBeenCalledWith('demo.backend', 'rollback', 'state-2')
|
||||
})
|
||||
|
||||
it('factor-page context extracts factors from a strategy-root package without selecting the strategy', async () => {
|
||||
mocks.inspect.mockResolvedValue({ kind: 'resources', preview: { roots: ['strategy:s'], revision: 'r', counts: { strategy: 1, factor: 1 }, entries: [
|
||||
{ key: 'strategy:s', kind: 'strategy', id: 's', label: '策略', action: 'create', dependencies: ['factor:f'], issues: [], local_dependencies: [], path: 's.py' },
|
||||
{ key: 'factor:f', kind: 'factor', id: 'f', label: '因子', action: 'create', dependencies: [], issues: [], local_dependencies: [], path: 'f.json' },
|
||||
] } })
|
||||
mocks.resources.mockResolvedValue({ imported: 1, reused: 0, counts: { factor: 1 } })
|
||||
await render('/settings?tab=import&kind=factor'); await pick('strategy.zip')
|
||||
await click(button('确认导入'))
|
||||
expect(mocks.resources).toHaveBeenCalledWith(expect.any(Array), ['factor:f'], [], 'r')
|
||||
})
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, ArrowRight, Check, CheckCircle2, Clock3, Download, FileCode2, FolderOpen, Info, Loader2, Package, Plus, RefreshCw, Server, ShieldAlert, Upload } from 'lucide-react'
|
||||
import { api, type ImportCenterPreview, type ManagedExtensionPackage, type PluginDataSourceItem, type ResourceKind, type RuntimePagePack } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { resourceClosure } from '@/lib/resourceTransfer'
|
||||
import { inspectPageBlob, inspectPageModule, refreshResourceQueries } from '@/lib/importCenter'
|
||||
import { runtimeBundleUrl } from '@/extensions/runtime'
|
||||
import { getFrontendExtensionLoadErrors } from '@/extensions/registry'
|
||||
import { ResourceTransferDialog } from '@/components/ResourceTransferDialog'
|
||||
import { PackageActions } from '@/custom/source-packages/extension'
|
||||
import { PluginKeyConfig } from './DataSources'
|
||||
import { INVALIDATE_KEYS, SettingsBackupPanel } from './Backup'
|
||||
|
||||
const BUTTON = 'inline-flex min-h-11 items-center justify-center gap-1.5 rounded-btn border border-border bg-surface px-3 py-2 text-xs font-medium text-secondary transition-colors hover:bg-elevated hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-45 sm:min-h-9'
|
||||
const PRIMARY = cn(BUTTON, 'border-blue-600 bg-blue-600 text-white hover:bg-blue-700 hover:text-white')
|
||||
const INPUT = 'min-h-11 w-full min-w-0 rounded-input border border-border bg-base px-3 py-2 text-base text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent sm:min-h-9 sm:text-sm'
|
||||
const KIND = { strategy: '策略', factor: '因子', signal: '信号', settings: '调参', code: '辅助模块' }
|
||||
const TYPE = { resources: '策略与定义资源', backup: '配置备份', source: '数据源插件', extension: '后端扩展', page: '页面插件', project: '项目源码页面' }
|
||||
type Review = ImportCenterPreview | { kind: 'project'; preview: { namespace: string } }
|
||||
type Result = { title: string; message: string; details: string[]; target?: string; path?: string }
|
||||
|
||||
function Notice({ children, warning = false }: { children: ReactNode; warning?: boolean }) {
|
||||
return <div className={cn('flex items-start gap-2 rounded-btn px-3 py-3 text-xs leading-6', warning ? 'bg-warning/10 text-amber-800 dark:text-amber-300' : 'bg-accent/10 text-blue-800 dark:text-blue-300')}>
|
||||
<Info className="mt-1 h-3.5 w-3.5 shrink-0" aria-hidden="true" /><div className="min-w-0 break-words">{children}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Status({ children }: { children: string }) {
|
||||
return <span className={cn('inline-flex max-w-full rounded px-2 py-1 text-[11px] leading-5',
|
||||
/失败|损坏/.test(children) ? 'bg-danger/10 text-danger' : /待|重启/.test(children) ? 'bg-warning/10 text-amber-800 dark:text-amber-300' : /已启用|已就绪/.test(children) ? 'bg-emerald-500/10 text-emerald-800 dark:text-emerald-300' : 'bg-elevated text-secondary')}>{children}</span>
|
||||
}
|
||||
|
||||
function Stepper({ step }: { step: number }) {
|
||||
return <ol className="mb-6 flex flex-wrap items-center gap-x-6 gap-y-3 text-xs text-secondary" aria-label="导入进度">
|
||||
{['选择文件', '预览确认', '处理结果'].map((label, index) => <li key={label} aria-current={index === step ? 'step' : undefined} className={cn('flex items-center gap-2', index === step && 'text-accent')}>
|
||||
<span className={cn('flex h-6 w-6 items-center justify-center rounded-full text-[11px]', index < step ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : index === step ? 'bg-accent/10' : 'bg-elevated')}>{index < step ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : index + 1}</span>{label}
|
||||
</li>)}
|
||||
</ol>
|
||||
}
|
||||
|
||||
const formatSize = (n: number) => n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MiB' : Math.max(1, Math.round(n / 1024)) + ' KiB'
|
||||
const toggle = (previous: Set<string>, key: string) => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
}
|
||||
|
||||
export function SettingsImportCenterPanel() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const view = params.get('view') ?? 'import'
|
||||
const qc = useQueryClient()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const busyRef = useRef(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [review, setReview] = useState<Review | null>(null)
|
||||
const [result, setResult] = useState<Result | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [trusted, setTrusted] = useState(false)
|
||||
const [selected, setSelected] = useState(new Set<string>())
|
||||
const [overwrite, setOverwrite] = useState(new Set<string>())
|
||||
const [categories, setCategories] = useState(new Set<string>())
|
||||
const [allowOverwrite, setAllowOverwrite] = useState(false)
|
||||
const [pageId, setPageId] = useState('')
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [exportKind, setExportKind] = useState<ResourceKind>(() => {
|
||||
const kind = params.get('kind')
|
||||
return kind === 'factor' || kind === 'signal' ? kind : 'strategy'
|
||||
})
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const packages = useQuery({ queryKey: QK.extensionPackages, queryFn: api.extensionPackages, enabled: view === 'installed', staleTime: 0 })
|
||||
const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources, enabled: view === 'installed' })
|
||||
const pages = useQuery({ queryKey: QK.extPages, queryFn: api.extPagesList, enabled: view === 'installed' || review?.kind === 'page' || review?.kind === 'project' })
|
||||
const namespaces = useQuery({ queryKey: QK.extPagesSourceNamespaces, queryFn: api.extPagesSourceNamespaces, enabled: view === 'import' && !review && !result })
|
||||
const matrix = useQuery({ queryKey: QK.capabilityMatrix, queryFn: api.capabilityMatrix, enabled: review?.kind === 'source' })
|
||||
const entries = review?.kind === 'resources' ? review.preview.entries : []
|
||||
const effective = useMemo(() => resourceClosure(entries, selected), [entries, selected])
|
||||
const included = entries.filter(entry => effective.has(entry.key))
|
||||
const conflicts = included.filter(entry => entry.action === 'conflict')
|
||||
const issues = included.flatMap(entry => entry.issues.map(issue => entry.label + ':' + issue))
|
||||
const unresolved = conflicts.some(entry => !overwrite.has(entry.key))
|
||||
const codeTrust = !!review && (['extension', 'source', 'page', 'project'].includes(review.kind) || included.some(entry => entry.kind === 'strategy' || entry.kind === 'code'))
|
||||
const packageErrors = review?.kind === 'extension' || review?.kind === 'source' ? review.preview.errors : []
|
||||
const isPage = review?.kind === 'page' || review?.kind === 'project'
|
||||
const invalidPageId = isPage && !/^[a-zA-Z0-9_]{1,64}$/.test(pageId)
|
||||
const pageConflict = isPage && (pages.data ?? []).some(page => page.id === pageId)
|
||||
const disabled = busy || !!packageErrors.length || !!issues.length || unresolved || (codeTrust && !trusted)
|
||||
|| (review?.kind === 'resources' && !included.length) || (review?.kind === 'backup' && (!categories.size || !trusted))
|
||||
|| !!invalidPageId || (isPage && (!pages.isSuccess || (pageConflict && !allowOverwrite)))
|
||||
|
||||
function changeView(next: string, target?: string) {
|
||||
if (busyRef.current) return
|
||||
const nextParams = new URLSearchParams(params)
|
||||
nextParams.set('tab', 'import')
|
||||
if (next === 'import') nextParams.delete('view')
|
||||
else nextParams.set('view', next)
|
||||
if (target) nextParams.set('selected', target)
|
||||
else nextParams.delete('selected')
|
||||
setParams(nextParams, { replace: true })
|
||||
setError('')
|
||||
}
|
||||
function reset() {
|
||||
if (busyRef.current) return
|
||||
setReview(null); setResult(null); setFiles([]); setError(''); setTrusted(false)
|
||||
setSelected(new Set()); setOverwrite(new Set()); setCategories(new Set()); setAllowOverwrite(false)
|
||||
changeView('import')
|
||||
}
|
||||
async function inspect(candidateFiles: File[]) {
|
||||
if (busyRef.current || !candidateFiles.length) return
|
||||
busyRef.current = true; setBusy(true); setError(''); setReview(null); setResult(null); setTrusted(false)
|
||||
setSelected(new Set()); setOverwrite(new Set()); setCategories(new Set()); setAllowOverwrite(false)
|
||||
setFiles(candidateFiles)
|
||||
try {
|
||||
const next = await api.importCenterInspect(candidateFiles)
|
||||
setReview(next)
|
||||
if (next.kind === 'resources') {
|
||||
const kind = params.get('kind')
|
||||
const candidates = next.preview.entries.filter(entry => !kind || entry.kind === kind).map(entry => entry.key)
|
||||
const preferred = next.preview.roots.filter(key => candidates.includes(key))
|
||||
setSelected(new Set(preferred.length ? preferred : candidates.length ? candidates : next.preview.roots))
|
||||
}
|
||||
if (next.kind === 'backup') setCategories(new Set(next.preview.categories.filter(c => c.key !== 'secrets' && c.key !== 'auth').map(c => c.key)))
|
||||
if (next.kind === 'page') setPageId(next.preview.suggested_id)
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '文件识别失败,请检查文件格式后重试') }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
async function refreshInstalled() {
|
||||
await Promise.allSettled([QK.extensionPackages, QK.extPages, QK.dataSources, QK.capabilityMatrix].map(queryKey => qc.invalidateQueries({ queryKey })))
|
||||
}
|
||||
async function apply() {
|
||||
if (!review || disabled || busyRef.current) return
|
||||
busyRef.current = true; setBusy(true); setError('')
|
||||
try {
|
||||
let next: Result
|
||||
if (review.kind === 'extension') {
|
||||
const preview = review.preview
|
||||
if (preview.manifest.frontend) await inspectPageBlob(await api.extensionPackageFrontend(files[0], preview.digest), preview.manifest.id)
|
||||
const applied = await api.installExtensionPackage(files[0], preview.digest, preview.revision)
|
||||
next = { title: '扩展包已保存', message: applied.message, target: 'extension:' + applied.id, details: [
|
||||
preview.installed_version ? '当前进程继续使用 ' + preview.installed_version + ',重启主程序后切换到 ' + preview.manifest.version + '。' : '扩展尚未启用;后台接口和启动任务在重启主程序后生效。',
|
||||
...(preview.manifest.frontend ? ['配套页面已通过加载契约检查;等待后台启动成功后,随同一版本启用。'] : []),
|
||||
'新版本后台启动失败时保留原版本;可在“已安装扩展”查看状态或回退。',
|
||||
] }
|
||||
} else if (review.kind === 'source') {
|
||||
const applied = await api.installSourcePackage(files[0], review.preview.digest, review.preview.revision)
|
||||
next = { title: '数据源插件已处理', message: applied.message, target: 'source:' + applied.name, details: ['安装不会自动接管取数。请继续配置 API Key,再明确选择启用的数据能力。'] }
|
||||
} else if (review.kind === 'backup') {
|
||||
const applied = await api.configBackupApply(files[0], allowOverwrite, [...categories])
|
||||
const failed = applied.categories.flatMap(c => c.failed.map(f => c.label + ' · ' + f.file + ':' + f.error))
|
||||
next = { title: failed.length ? '配置恢复完成,部分条目失败' : '所选配置恢复完成', message: '成功恢复 ' + applied.categories.reduce((sum, c) => sum + c.imported, 0) + ' 项,跳过同名项 ' + applied.categories.reduce((sum, c) => sum + c.skipped_conflict, 0) + ' 项。', details: failed.length ? failed : ['只应用已勾选的类别;未选类别保持原样。', ...(categories.has('auth') ? ['访问凭证已恢复,可能需要使用备份中的密码重新登录。'] : [])] }
|
||||
await Promise.allSettled(INVALIDATE_KEYS.map(queryKey => qc.invalidateQueries({ queryKey })))
|
||||
await refreshResourceQueries(qc)
|
||||
} else if (review.kind === 'resources') {
|
||||
const applied = await api.resourcesApply(files, [...selected], conflicts.filter(entry => overwrite.has(entry.key)).map(entry => entry.key), review.preview.revision)
|
||||
next = { title: '资源已导入', message: '新增或替换 ' + applied.imported + ' 个文件,复用 ' + applied.reused + ' 个已有文件。', details: [
|
||||
...(['strategy', 'signal', 'factor'] as const).filter(kind => applied.counts[kind]).map(kind => KIND[kind] + ' ' + applied.counts[kind] + ' 项'),
|
||||
...(applied.drafts?.length ? [applied.drafts.length + ' 条策略保留为草稿,请在策略库中检查后发布。'] : []),
|
||||
], path: included.some(e => e.kind === 'strategy') ? '/screener' : included.some(e => e.kind === 'factor') ? '/factors' : '/signals' }
|
||||
await refreshResourceQueries(qc)
|
||||
} else {
|
||||
if (review.kind === 'page' && !review.preview.source) await inspectPageBlob(files[0])
|
||||
const meta = review.kind === 'project'
|
||||
? await api.importCenterProjectPage(review.preview.namespace, pageId, allowOverwrite)
|
||||
: await api.importCenterInstallPage(files[0], pageId, review.preview.digest, allowOverwrite)
|
||||
let summary = { label: '', path: '' }
|
||||
try {
|
||||
summary = await inspectPageModule(runtimeBundleUrl(meta.id) + '?trial=' + Date.now())
|
||||
await api.extPagesPutManifest(meta.id, { ...summary, error: '' })
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '页面加载校验失败'
|
||||
await api.extPagesPutManifest(meta.id, { ...summary, error: message })
|
||||
throw new Error(message + '。页面已保持停用,请修正后重新导入。')
|
||||
}
|
||||
next = { title: '页面已通过加载校验', message: '刷新浏览器后登记页面和菜单入口。', details: ['页面只使用主程序已有接口。如需配套后台,请导入前后端完整扩展包。'], target: 'page:' + meta.id }
|
||||
}
|
||||
setResult(next)
|
||||
await refreshInstalled()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '处理失败,请检查后重试') }
|
||||
finally { busyRef.current = false; setBusy(false) }
|
||||
}
|
||||
|
||||
const packageDoc = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.manifest : null
|
||||
const installedVersion = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.installed_version : null
|
||||
const pendingVersion = review?.kind === 'source' || review?.kind === 'extension' ? review.preview.pending_version : null
|
||||
const name = review?.kind === 'extension' ? review.preview.manifest.display_name || review.preview.manifest.id
|
||||
: review?.kind === 'source' ? review.preview.manifest.display_name || review.preview.manifest.name
|
||||
: review?.kind === 'project' ? review.preview.namespace : review?.kind === 'backup' ? '恢复配置备份' : review?.kind === 'resources' ? '策略、信号与因子' : '导入页面插件'
|
||||
const selectedItem = params.get('selected') ?? ''
|
||||
const liveErrors = getFrontendExtensionLoadErrors()
|
||||
|
||||
return <div className="max-w-5xl space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><h2 className="text-xl font-semibold tracking-tight text-foreground">导入与备份</h2><p className="mt-2 text-sm leading-6 text-secondary">一个入口,导入资源、安装扩展、迁移配置。</p></div>
|
||||
<button type="button" className={BUTTON} disabled={busy} onClick={() => changeView('backup')}><Download className="h-4 w-4" aria-hidden="true" />创建配置备份</button>
|
||||
</header>
|
||||
<nav className="flex gap-6 border-b border-border" aria-label="导入与扩展">
|
||||
{[['import', '导入文件'], ['installed', '已安装扩展']].map(([key, label]) => <button key={key} type="button" aria-pressed={key === 'import' ? view !== 'installed' : view === key} disabled={busy} onClick={() => changeView(key)}
|
||||
className={cn('min-h-11 border-b-2 pb-3 text-sm font-medium disabled:opacity-50', (key === 'import' ? view !== 'installed' : view === key) ? 'border-accent text-accent' : 'border-transparent text-secondary hover:text-foreground')}>{label}</button>)}
|
||||
</nav>
|
||||
|
||||
{view === 'backup' ? <><SettingsBackupPanel mode="export" /><button type="button" className={BUTTON} onClick={() => changeView('import')}><ArrowLeft className="h-3.5 w-3.5" />返回导入</button></>
|
||||
: view === 'installed' ? <InstalledView packages={packages.data ?? []} sources={(sources.data?.plugins ?? []).filter(p => p.origin === 'external')} pages={(pages.data ?? []).filter(p => !p.managed_by)}
|
||||
loading={packages.isLoading || sources.isLoading || pages.isLoading} error={packages.error || sources.error || pages.error}
|
||||
selected={selectedItem} select={target => changeView('installed', target)} install={reset} refresh={refreshInstalled} bootErrors={liveErrors} />
|
||||
: <>
|
||||
<Stepper step={result ? 2 : review ? 1 : 0} />
|
||||
{result ? <section className="max-w-2xl py-2" role="status">
|
||||
<CheckCircle2 className="mb-4 h-9 w-9 text-emerald-700 dark:text-emerald-300" aria-hidden="true" />
|
||||
<h3 className="text-lg font-semibold text-foreground">{result.title}</h3><p className="mt-3 text-sm leading-7 text-secondary">{result.message}</p>
|
||||
<ul className="my-6 space-y-3 border-y border-border py-5 text-sm leading-6 text-secondary">{result.details.map((detail, i) => <li key={i} className="break-words">{detail}</li>)}</ul>
|
||||
<div className="flex flex-wrap gap-3">{result.target && <button type="button" className={PRIMARY} onClick={() => changeView('installed', result.target)}>查看已安装扩展<ArrowRight className="h-3.5 w-3.5" /></button>}
|
||||
{result.path && <Link to={result.path} className={PRIMARY}>查看导入资源<ArrowRight className="h-3.5 w-3.5" /></Link>}
|
||||
<button type="button" className={BUTTON} onClick={reset}>继续导入</button></div>
|
||||
</section> : review ? <>
|
||||
<header className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-secondary"><Package className="h-4 w-4" aria-hidden="true" /><span>{review.kind === 'extension' && review.preview.manifest.frontend ? '完整功能包' : TYPE[review.kind]}</span><span className="break-all">{review.kind === 'project' ? '项目源码 · ' + review.preview.namespace : files.map(f => f.name).join('、')}</span>{!!files.length && <span>{formatSize(files.reduce((sum, f) => sum + f.size, 0))}</span>}</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">{name}</h3>
|
||||
{packageDoc && <p className="text-xs text-secondary">{installedVersion ? '本机 ' + installedVersion + ' → 目标 ' + packageDoc.version : '版本 ' + packageDoc.version}{packageDoc.author ? ' · 来源声明:' + packageDoc.author : ''}</p>}
|
||||
{packageDoc?.description && <p className="max-w-2xl text-sm leading-6 text-secondary">{packageDoc.description}</p>}
|
||||
{pendingVersion && packageDoc && <Notice warning>已有待生效版本 {pendingVersion}。{pendingVersion === packageDoc.version ? '本次是同一个暂存版本。' : '确认后会用 ' + packageDoc.version + ' 替换该暂存版本,当前运行版本保持不变。'}</Notice>}
|
||||
</header>
|
||||
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div className="min-w-0 space-y-7">
|
||||
{review.kind === 'resources' && <section>
|
||||
<h4 className="mb-3 text-sm font-semibold text-foreground">选择资源与检查依赖</h4>
|
||||
<div className="divide-y divide-border border-y border-border">{entries.filter(entry => ['strategy', 'signal', 'factor'].includes(entry.kind)).map(entry => <label key={entry.key} className="flex items-start gap-3 py-3 text-sm">
|
||||
<input className="mt-1 accent-accent" type="checkbox" checked={selected.has(entry.key)} disabled={busy || (effective.has(entry.key) && !selected.has(entry.key))} onChange={() => setSelected(previous => toggle(previous, entry.key))} aria-label={'选择' + entry.label} />
|
||||
<span className="min-w-0 flex-1 break-words"><span className="font-medium text-foreground">{entry.label}</span><span className="mt-1 block text-xs text-secondary">{KIND[entry.kind]} · {entry.id}{effective.has(entry.key) && !selected.has(entry.key) ? ' · 随所选资源自动携带' : ''}</span></span>
|
||||
<span className="text-xs text-secondary">{entry.action === 'reuse' ? '复用' : entry.action === 'conflict' ? '内容不同' : '新增'}</span>
|
||||
</label>)}</div>
|
||||
{!!conflicts.length && <div className="mt-4 space-y-3"><Notice warning>所选资源或依赖与本机内容不同。确认替换,或取消引用它的主资源后继续。</Notice>{conflicts.map(entry => <div key={entry.key} className="space-y-2 text-xs leading-6 text-secondary">
|
||||
<label className="flex items-start gap-2"><input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={overwrite.has(entry.key)} onChange={() => setOverwrite(previous => toggle(previous, entry.key))} aria-label={'确认替换' + entry.label} /><span>用包内版本替换“{entry.label}”</span></label>
|
||||
{!!entry.referenced_by?.length && <p className="break-words">本机引用方:{entry.referenced_by.join('、')}</p>}
|
||||
{entry.comparison && <details><summary className="cursor-pointer text-accent">对比本机与包内内容</summary><div className="mt-2 grid gap-3 sm:grid-cols-2">{(['local', 'incoming'] as const).map(key => <div key={key}><p>{key === 'local' ? '本机版本' : '包内版本'}</p><pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-input bg-elevated p-3">{entry.comparison![key]}</pre></div>)}</div></details>}
|
||||
</div>)}</div>}
|
||||
<p className="mt-4 text-xs leading-6 text-secondary">所选 {included.length} 项文件,包含关联定义、策略调参及辅助模块。相同内容自动复用。</p>
|
||||
</section>}
|
||||
{review.kind === 'backup' && <section>
|
||||
<h4 className="mb-3 text-sm font-semibold text-foreground">选择恢复范围</h4>
|
||||
<p className="mb-3 text-xs leading-6 text-secondary">备份时间 {review.preview.created_at || '未知'} · 来源版本 {review.preview.app_version || '未知'}</p>
|
||||
<div className="divide-y divide-border border-y border-border">{review.preview.categories.map(category => <label key={category.key} className="flex items-start gap-3 py-3 text-sm">
|
||||
<input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={categories.has(category.key)} onChange={() => setCategories(previous => toggle(previous, category.key))} aria-label={'恢复' + category.label} />
|
||||
<span className="min-w-0 flex-1"><span className="text-foreground">{category.label}</span><span className="mt-1 block text-xs text-secondary">{category.count} 项{category.conflicts ? ' · ' + category.conflicts + ' 项同名' : ''}</span></span>
|
||||
{['secrets', 'auth'].includes(category.key) && <span className="text-xs text-amber-800 dark:text-amber-300">敏感项</span>}
|
||||
</label>)}</div>
|
||||
<div className="mt-4"><Notice warning>密钥(API Key)和访问凭证需单独勾选。偏好与密钥按合并写入;恢复敏感项会覆盖本机同名值。</Notice></div>
|
||||
{!!review.preview.conflict_files && <label className="mt-4 flex items-start gap-2 text-xs leading-6 text-secondary"><input type="checkbox" checked={allowOverwrite} disabled={busy} onChange={e => setAllowOverwrite(e.target.checked)} className="mt-1 accent-accent" />允许覆盖已存在的同名文件(默认跳过)</label>}
|
||||
{!!review.preview.out_of_scope_files && <p className="mt-3 text-xs text-secondary">{review.preview.out_of_scope_files} 个不受支持的文件不会恢复。</p>}
|
||||
</section>}
|
||||
{review.kind === 'extension' && <section className="space-y-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">本次变更</h4>
|
||||
<div className="flex items-start gap-3 border-b border-border pb-4"><Server className="mt-1 h-4 w-4 shrink-0 text-secondary" /><div><p className="text-sm font-medium text-foreground">后端服务与启动任务</p><p className="mt-1 text-xs leading-6 text-secondary">重启后注册扩展接口,执行包内声明的初始化任务。</p></div></div>
|
||||
{review.preview.manifest.frontend && <div className="flex items-start gap-3 border-b border-border pb-4"><FileCode2 className="mt-1 h-4 w-4 shrink-0 text-secondary" /><div><p className="text-sm font-medium text-foreground">配套前端页面</p><p className="mt-1 text-xs leading-6 text-secondary">与后端按同一版本更新、启停和卸载。</p></div></div>}
|
||||
<Notice>{review.preview.manifest.frontend ? '后台启动成功后,新版本页面才会对外提供。确认安装时会先加载页面代码检查其前端契约。' : '新版本仅在主程序重启时加载;安装和预览阶段不会执行包内 Python 代码。'}</Notice>
|
||||
<details className="text-xs leading-6 text-secondary"><summary className="cursor-pointer">查看包内文件与技术信息</summary><p className="mt-2">接口命名空间:/api/extensions/{review.preview.manifest.id}/</p><ul className="mt-2 break-all font-mono">{review.preview.files.map(file => <li key={file}>{file}</li>)}</ul></details>
|
||||
</section>}
|
||||
{review.kind === 'source' && <section className="space-y-4"><h4 className="text-sm font-semibold text-foreground">本次变更</h4><p className="text-sm leading-7 text-secondary">安装数据源卡片。支持能力:{review.preview.manifest.datasets.map(id => matrix.data?.capabilities.find(c => c.id === id)?.label || id).join('、')}。</p><Notice>安装后继续配置 API Key 和启用能力。新版本更新在重启后切换,安装不会自动更改当前行情来源。</Notice></section>}
|
||||
{isPage && <section className="space-y-4"><h4 className="text-sm font-semibold text-foreground">页面与菜单入口</h4><label className="block space-y-2 text-xs text-secondary"><span>页面标识</span><input aria-label="页面标识" value={pageId} disabled={busy} onChange={e => { setPageId(e.target.value); setAllowOverwrite(false) }} className={INPUT} /></label>
|
||||
{invalidPageId && <p role="alert" className="text-xs text-danger">标识需为 1–64 位字母、数字或下划线。</p>}
|
||||
{pageConflict && <label className="flex items-start gap-2 text-xs leading-6 text-secondary"><input className="mt-1 accent-accent" type="checkbox" disabled={busy} checked={allowOverwrite} onChange={e => setAllowOverwrite(e.target.checked)} />确认替换该标识现有的页面插件</label>}
|
||||
{pages.error && <p role="alert" className="text-xs text-danger">本机页面清单读取失败。<button className="ml-2 underline" onClick={() => void pages.refetch()}>重新读取</button></p>}
|
||||
<Notice>这里只导入前端页面,加载校验通过后刷新生效。需要新的后台接口时,请使用包含 extension.yaml 的完整功能包。</Notice>
|
||||
</section>}
|
||||
{(review.kind === 'source' || review.kind === 'extension') && <section className="space-y-3"><h4 className="text-sm font-semibold text-foreground">兼容检查</h4>{packageErrors.length ? <div role="alert" className="space-y-2 text-sm text-danger">{packageErrors.map((message, i) => <p key={i}>{message}</p>)}</div> : <p className="flex items-center gap-2 text-sm text-emerald-800 dark:text-emerald-300"><CheckCircle2 className="h-4 w-4" />静态兼容检查通过</p>}<p className="text-xs leading-6 text-secondary">缺少依赖时停止安装,不会自动修改主程序运行环境。</p></section>}
|
||||
{!!issues.length && <div role="alert" className="space-y-2 text-xs leading-6 text-danger">{issues.map((issue, i) => <p key={i}>{issue}</p>)}</div>}
|
||||
</div>
|
||||
<aside className="min-w-0 rounded-card border border-border bg-surface p-4">
|
||||
<h4 className="text-sm font-semibold text-foreground">{review.kind === 'backup' ? '本次恢复' : '本次操作'}</h4>
|
||||
<dl className="my-4 space-y-3 text-xs leading-6"><div className="flex justify-between gap-3"><dt className="text-secondary">处理方式</dt><dd className="text-right text-foreground">{review.kind === 'backup' ? '按类别恢复' : review.kind === 'resources' ? '所选资源与依赖' : 'installed_version' in review.preview && review.preview.installed_version ? '更新已有扩展' : '新增导入'}</dd></div>
|
||||
<div className="flex justify-between gap-3"><dt className="text-secondary">生效时间</dt><dd className="text-right text-foreground">{review.kind === 'extension' ? '重启主程序后' : isPage ? '刷新页面后' : review.kind === 'source' && review.preview.installed_version ? '重启主程序后' : '处理完成后'}</dd></div>
|
||||
{review.kind === 'backup' && <div className="flex justify-between gap-3"><dt className="text-secondary">恢复范围</dt><dd>{categories.size} / {review.preview.categories.length} 类</dd></div>}
|
||||
{packageDoc && <div className="flex justify-between gap-3"><dt className="text-secondary">目标版本</dt><dd>{packageDoc.version}</dd></div>}
|
||||
</dl>
|
||||
{(codeTrust || review.kind === 'backup') && <label className="flex items-start gap-2 text-xs leading-6 text-secondary"><input type="checkbox" className="mt-1 accent-accent" disabled={busy} aria-label={review.kind === 'backup' ? '信任备份来源' : '信任代码来源'} checked={trusted} onChange={e => setTrusted(e.target.checked)} /><span>{review.kind === 'backup' ? '我确认这是可信的备份,允许恢复所选配置及其中的代码。' : '我信任此代码来源,允许它在本机运行。代码可访问主程序的数据和凭据。'}</span></label>}
|
||||
<button type="button" className={cn(PRIMARY, 'mt-4 w-full whitespace-normal')} disabled={disabled} onClick={() => void apply()}>{busy && <Loader2 className="h-4 w-4 animate-spin" />}{busy ? '正在处理…' : review.kind === 'backup' ? '恢复所选配置' : review.kind === 'resources' ? '确认导入' : 'installed_version' in review.preview && review.preview.installed_version ? '确认更新' : '确认安装'}</button>
|
||||
<p className="mt-3 text-xs leading-6 text-secondary">确认前不会应用任何变更。{unresolved ? '请先处理同名冲突。' : ''}</p>
|
||||
</aside>
|
||||
</div>
|
||||
<button type="button" className={BUTTON} disabled={busy} onClick={reset}><ArrowLeft className="h-3.5 w-3.5" />重新选择文件</button>
|
||||
</> : <>
|
||||
<div className={cn('rounded-card border border-dashed bg-surface px-4 py-10 text-center transition-colors sm:py-12', dragging ? 'border-accent bg-accent/5' : 'border-border')}
|
||||
onDragOver={e => { e.preventDefault(); if (!busy) setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={e => { e.preventDefault(); setDragging(false); void inspect(Array.from(e.dataTransfer.files)) }}>
|
||||
<Upload className="mx-auto mb-4 h-7 w-7 text-secondary" aria-hidden="true" /><h3 className="text-lg font-semibold text-foreground">把文件放到这里</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-7 text-secondary">自动识别策略与定义、配置备份、数据源插件、页面插件,以及后端或前后端完整扩展包。</p>
|
||||
<button type="button" className={cn(PRIMARY, 'mt-5')} disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <FolderOpen className="h-4 w-4" />}{busy ? '正在识别文件…' : '选择文件'}</button>
|
||||
<p className="mt-3 text-xs leading-6 text-secondary">支持 ZIP、PY、JSON、JS、MJS、TSX。资源文件可多选;扩展和备份逐包处理。</p>
|
||||
</div>
|
||||
{!!namespaces.data?.length && <details className="border-b border-border pb-5 text-xs text-secondary"><summary className="cursor-pointer">从本项目的源码页面导入</summary><p className="mt-3 leading-6">把现有源码页面编译为独立页面插件;不会把它的后端代码一起打包。</p><div className="mt-3 flex flex-wrap items-center gap-2"><select aria-label="项目源码页面" className={cn(INPUT, 'sm:w-64')} value={namespace} onChange={e => setNamespace(e.target.value)} disabled={busy}><option value="">选择源码页面</option>{namespaces.data.map(ns => <option key={ns} value={ns}>{ns}</option>)}</select><button type="button" className={BUTTON} disabled={busy || !namespace} onClick={() => { setReview({ kind: 'project', preview: { namespace } }); setPageId(namespace.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 64)); setTrusted(false); setAllowOverwrite(false) }}>预览项目页面</button></div></details>}
|
||||
<section className="flex flex-wrap items-end justify-between gap-4 border-b border-border pb-5">
|
||||
<div><h3 className="text-sm font-medium text-foreground">分享策略、信号与因子</h3><p className="mt-2 text-xs leading-6 text-secondary">批量导出定义和关联依赖;换机迁移请使用“创建配置备份”。</p></div>
|
||||
<div className="flex flex-wrap gap-2"><select aria-label="导出资源类型" className={cn(INPUT, 'w-auto')} value={exportKind} onChange={e => setExportKind(e.target.value as ResourceKind)}>{(['strategy', 'signal', 'factor'] as const).map(kind => <option key={kind} value={kind}>{KIND[kind]}</option>)}</select><button type="button" className={BUTTON} onClick={() => setExporting(true)}><Download className="h-3.5 w-3.5" />批量导出资源</button></div>
|
||||
</section>
|
||||
<Notice>导入前会展示变更范围、依赖和冲突。后端扩展安装到用户数据目录,升级主程序不会覆盖安装文件。</Notice>
|
||||
</>}
|
||||
<input ref={fileInput} type="file" className="hidden" aria-label="选择导入文件" multiple accept=".zip,.py,.json,.js,.mjs,.tsx" disabled={busy} onChange={e => { const next = Array.from(e.target.files ?? []); e.target.value = ''; void inspect(next) }} />
|
||||
</>}
|
||||
{error && <div role="alert" className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/5 p-3 text-sm leading-6 text-danger"><ShieldAlert className="mt-1 h-4 w-4 shrink-0" /><span className="min-w-0 break-words">{error}</span>{!review && !!files.length && <button type="button" className="shrink-0 underline" disabled={busy} onClick={() => void inspect(files)}>重新预览</button>}</div>}
|
||||
{exporting && <ResourceTransferDialog kind={exportKind} initialMode="export" exportOnly onClose={() => setExporting(false)} />}
|
||||
</div>
|
||||
}
|
||||
|
||||
function InstalledView({ packages, sources, pages, selected, select, install, refresh, loading, error, bootErrors }: {
|
||||
packages: ManagedExtensionPackage[]; sources: PluginDataSourceItem[]; pages: RuntimePagePack[]
|
||||
selected: string; select: (target: string) => void; install: () => void; refresh: () => Promise<void>
|
||||
loading: boolean; error: unknown; bootErrors: { source: string; error: string; extensionId?: string }[]
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const pack = packages.find(p => selected === 'extension:' + p.id)
|
||||
const source = sources.find(p => selected === 'source:' + p.name)
|
||||
const page = pages.find(p => selected === 'page:' + p.id)
|
||||
const items = [
|
||||
...packages.map(p => ({ key: 'extension:' + p.id, title: p.name, subtitle: (p.frontend ? '前端 + 后端' : '后端扩展') + ' · ' + (p.version || '尚未生效') + (p.pending_version ? ' → ' + p.pending_version : ''), status: p.status, icon: Server })),
|
||||
...sources.map(p => ({ key: 'source:' + p.name, title: p.display_name, subtitle: '数据源插件 · ' + (p.version || '—') + (p.pending_version ? ' → ' + p.pending_version : ''), status: p.pending_remove ? '待重启卸载' : p.update_error ? '更新失败' : p.pending_version ? '待重启更新' : p.status, icon: Package })),
|
||||
...pages.map(p => ({ key: 'page:' + p.id, title: p.label || p.id, subtitle: '页面插件 · ' + (p.path || '尚未登记页面路径'), status: p.error ? '加载失败' : p.enabled ? '已启用' : '已停用', icon: FileCode2 })),
|
||||
]
|
||||
async function run(action: string) {
|
||||
if (busy) return
|
||||
setBusy(true); setFailure(''); setMessage('')
|
||||
try {
|
||||
if (pack) setMessage((await api.manageExtensionPackage(pack.id, action, pack.revision)).message)
|
||||
if (page) {
|
||||
if (action === 'remove') await api.extPagesDelete(page.id)
|
||||
else await api.extPagesPutEnabled(page.id, action === 'enable')
|
||||
setMessage('变更已保存,刷新浏览器后生效')
|
||||
}
|
||||
setConfirm('')
|
||||
await refresh()
|
||||
} catch (e) { setFailure(e instanceof Error ? e.message : '操作失败,请重试'); await refresh() }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const activeError = pack?.error || page?.error
|
||||
return <>
|
||||
<header className="flex flex-wrap items-start justify-between gap-3"><div><h3 className="text-lg font-semibold text-foreground">已安装扩展</h3><p className="mt-2 text-xs leading-6 text-secondary">统一查看版本、启停状态与待生效变更。</p></div><div className="flex gap-2"><button className={BUTTON} disabled={busy || loading} onClick={() => void refresh()} aria-label="刷新扩展清单"><RefreshCw className="h-3.5 w-3.5" /></button><button className={BUTTON} disabled={busy} onClick={install}><Plus className="h-3.5 w-3.5" />安装扩展</button></div></header>
|
||||
{!!error && <div role="alert" className="text-sm text-danger">扩展清单读取失败,已保留可用列表。请点击刷新重试。</div>}
|
||||
{loading && <p role="status" className="flex gap-2 text-sm text-secondary"><Loader2 className="h-4 w-4 animate-spin" />正在读取扩展…</p>}
|
||||
<div className={cn('grid items-start gap-6', (pack || source || page) && 'xl:grid-cols-[minmax(0,1fr)_19rem]')}>
|
||||
<div className="min-w-0 divide-y divide-border border-y border-border">
|
||||
{!loading && !items.length && <p className="py-12 text-sm leading-7 text-secondary">尚未安装外部扩展。可从“导入文件”开始;源码内置页面仍在“页面管理”维护。</p>}
|
||||
{items.map(item => <button key={item.key} type="button" disabled={busy} aria-pressed={selected === item.key} onClick={() => { setFailure(''); setMessage(''); setConfirm(''); select(item.key) }} className={cn('flex w-full flex-wrap items-center gap-3 py-4 text-left hover:bg-elevated/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-50', selected === item.key && 'text-accent')}>
|
||||
<item.icon className="h-4 w-4 shrink-0 text-secondary" aria-hidden="true" /><span className="min-w-0 flex-1 basis-40"><span className="block break-words text-sm font-medium">{item.title}</span><span className="mt-1 block break-words text-xs leading-6 text-secondary">{item.subtitle}</span></span><Status>{item.status}</Status>
|
||||
</button>)}
|
||||
</div>
|
||||
{(pack || source || page) && <aside className="min-w-0 rounded-card border border-border bg-surface p-4">
|
||||
<h4 className="break-words text-sm font-semibold text-foreground">{pack?.name || source?.display_name || page?.label || page?.id}</h4>
|
||||
{pack && <><dl className="my-4 space-y-3 text-xs leading-6"><div className="flex justify-between gap-3"><dt className="text-secondary">当前版本</dt><dd>{pack.version || '尚未生效'}</dd></div>{pack.pending_version && <div className="flex justify-between gap-3"><dt className="text-secondary">待生效版本</dt><dd>{pack.pending_version}</dd></div>}<div className="flex justify-between gap-3"><dt className="text-secondary">当前进程</dt><dd>{pack.runtime_version ? '已加载 ' + pack.runtime_version : '未加载'}</dd></div></dl>
|
||||
<p className="text-xs leading-6 text-secondary">{pack.frontend ? '前端页面与后台服务一起更新、启停和卸载。' : '后端接口、初始化任务和通知格式化器在重启后生效。'}</p>
|
||||
{!pack.pending_remove && <div className="mt-4 flex flex-wrap gap-2"><button className={BUTTON} disabled={busy} onClick={() => void run(pack.enabled ? 'disable' : 'enable')}>{pack.enabled ? '停用扩展' : '启用扩展'}</button>{pack.previous_version && <button className={BUTTON} disabled={busy} onClick={() => setConfirm('rollback')}>{'回退到 ' + pack.previous_version}</button>}{pack.pending_version && pack.version && <button className={BUTTON} disabled={busy} onClick={() => void run('cancel-update')}>取消待生效更新</button>}<button className={cn(BUTTON, 'text-danger')} disabled={busy} onClick={() => setConfirm('remove')}>卸载扩展</button></div>}
|
||||
<p className="mt-4 flex items-start gap-2 text-xs leading-6 text-secondary"><Clock3 className="mt-1 h-3.5 w-3.5 shrink-0" />变更后请重启主程序;刷新浏览器不能替代后台重启。</p>
|
||||
</>}
|
||||
{source && <div className="mt-4 space-y-4">{source.api_key_env && <PluginKeyConfig key={source.name} plugin={source} />}<PackageActions key={source.name} plugin={source} refresh={() => { void refresh() }} /></div>}
|
||||
{page && <><p className="mt-3 break-all text-xs leading-6 text-secondary">页面标识:{page.id}</p><p className="mt-2 text-xs leading-6 text-secondary">启停和卸载在刷新浏览器后生效。更新时从“导入文件”上传新产物,并确认替换相同标识。</p><div className="mt-4 flex flex-wrap gap-2"><button className={BUTTON} disabled={busy || !!page.error} onClick={() => void run(page.enabled ? 'disable' : 'enable')}>{page.enabled ? '停用页面' : '启用页面'}</button><button className={cn(BUTTON, 'text-danger')} disabled={busy} onClick={() => setConfirm('remove')}>卸载页面</button></div></>}
|
||||
{activeError && <p className="mt-4 break-words text-xs leading-6 text-danger">{pack?.failed_version ? '版本 ' + pack.failed_version + ' 启动失败;' + (pack.runtime_version ? '当前仍运行 ' + pack.runtime_version + '。' : '扩展未启用。') : activeError}</p>}
|
||||
{confirm && <div className="mt-5 space-y-3 border-t border-border pt-4"><p className="text-xs leading-6 text-secondary">{confirm === 'remove' ? '确认卸载此扩展?扩展包及关联页面将移除,包目录外的数据保留。' : '确认回退:' + pack?.version + ' → ' + pack?.previous_version + '。重启后切换,原有启停状态保留。' + (pack?.pending_version ? '已暂存的 ' + pack.pending_version + ' 将被替换。' : '')}</p><div className="flex flex-wrap gap-2"><button className={BUTTON} disabled={busy} onClick={() => setConfirm('')}>取消</button><button className={cn(BUTTON, confirm === 'remove' && 'text-danger')} disabled={busy} onClick={() => void run(confirm)}>{busy ? '处理中…' : '确认'}</button></div></div>}
|
||||
</aside>}
|
||||
</div>
|
||||
{failure && <p role="alert" className="break-words text-sm text-danger">{failure}</p>}
|
||||
{message && <p role="status" className="text-sm leading-6 text-secondary">{message}</p>}
|
||||
{!!bootErrors.filter(e => e.source.startsWith('runtime:')).length && <Notice warning><p className="font-medium">本次启动有页面加载失败</p>{bootErrors.filter(e => e.source.startsWith('runtime:')).map((e, i) => <p key={i} className="break-words">{e.source}:{e.error}</p>)}<p>请停用或更新对应页面,避免与主程序或其他扩展的路由冲突。</p></Notice>}
|
||||
</>
|
||||
}
|
||||
@@ -5,16 +5,14 @@
|
||||
* ?tab=menus/ext-pages/page-packs 链接重写为 ?tab=pages§ion=<旧值>, 历史
|
||||
* 链接与 router 内的 /analysis 重定向均不失效。
|
||||
*/
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { SettingsMenuSettingsPanel } from './MenuSettings'
|
||||
import { SettingsExtPagesPanel } from './ExtPages'
|
||||
import { SettingsPagePacksPanel } from './PagePacks'
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'menus', label: '菜单与页面' },
|
||||
{ key: 'ext-pages', label: '扩展页面' },
|
||||
{ key: 'page-packs', label: '页面插件' },
|
||||
] as const
|
||||
|
||||
type SectionKey = (typeof SECTIONS)[number]['key']
|
||||
@@ -48,11 +46,11 @@ export function SettingsPageAdminPanel() {
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<Link to="/settings?tab=import" className="ml-auto rounded-lg border border-border px-3 py-1.5 text-sm text-secondary hover:bg-elevated">导入或管理页面插件</Link>
|
||||
</div>
|
||||
|
||||
{section === 'menus' && <SettingsMenuSettingsPanel />}
|
||||
{section === 'ext-pages' && <SettingsExtPagesPanel />}
|
||||
{section === 'page-packs' && <SettingsPagePacksPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user