feat(import): 统一导入备份入口并支持后端扩展包

集中处理策略与定义、配置备份、数据源和页面插件导入,保留旧入口跳转。新增可信后端及前后端完整包的预检、暂存、重启生效、失败回退和版本管理。

验证:扩展、资源、备份与页面定向 pytest 通过,前端 41 项测试及生产构建通过,并完成桌面、窄屏和明暗主题联调。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-11-01 20:01:59 +08:00
parent 316ae2954a
commit ffe1ff0d3e
33 changed files with 2004 additions and 165 deletions
+15 -1
View File
@@ -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:
+136
View File
@@ -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
+2 -2
View File
@@ -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
+10 -74
View File
@@ -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"]
+433
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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)
+2
View File
@@ -30,6 +30,8 @@ class PagePackMeta:
uploaded_at: float = 0.0
# 前端试载/注册失败的错误信息; 空串表示未出错
error: str = ""
managed_by: str = ""
version: str = ""
def _sha256_file(path: Path) -> str:
+71
View File
@@ -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}
+92
View File
@@ -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
+303
View File
@@ -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:]
+95
View File
@@ -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
+12
View File
@@ -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):
+102
View File
@@ -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`,只在对应后台版本就绪时提供。启停和卸载通过整包管理,不能拆开修改。
+4 -4
View File
@@ -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
View File
@@ -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 只导入前端页面。
+2 -1
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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({
+6 -1
View File
@@ -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
View File
@@ -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) =>
+26
View File
@@ -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) }
}
+1
View File
@@ -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,
+24 -9
View File
@@ -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
+11 -12
View File
@@ -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 && (
+1 -1
View File
@@ -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>}
</>
}
+2 -4
View File
@@ -5,16 +5,14 @@
* ?tab=menus/ext-pages/page-packs 链接重写为 ?tab=pages&section=<旧值>, 历史
* 链接与 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>
)
}