Files
tick-stock-panel/backend/app/api/pages_admin.py
T
shy3130 01257c83ee feat(pages): 设置三页合一为「页面管理」+ 源码扩展删除/URL 嵌入页/源码导入
- 菜单设置/扩展页面/页面插件三个设置 tab 合并为「页面管理」(?tab=pages, 旧链接与 /analysis 重定向兼容)
- 源码扩展页(frontend/src/custom/<ns>/)支持界面内物理删除: 先自动备份 zip 到 data/backups/pages/(可下载)再删目录, 命名校验+防穿越, 产物部署无源码树 409
- 扩展页面新增 URL 嵌入模板: 仅填菜单名+网址生成 iframe 内嵌页, 复用看板外链安全约定(仅 http(s)/拒同源/sandbox 白名单/新窗口兜底)
- 页面插件新增源码导入: 上传 .tsx 或从本项目 custom/ 选择, 后端用自带 esbuild 编译+契约适配(component→mount, 独立 react 实例自挂 Provider/Router, 图标映射白名单), 产物即标准页面插件; 无工具链部署 409
2026-10-22 17:47:59 +08:00

89 lines
3.6 KiB
Python

"""页面管理 API — 源码扩展页的物理删除(带强制备份)与备份下载。
删除语义 (维护者决策): 「删除」= 物理移除 frontend/src/custom/<ns>/ 源码目录,
dev 热重载/下次构建后该页面从产物消失。删除前必须先打包备份到
data/backups/pages/ (data/ 不进 git), 压缩失败则整个请求失败、目录保持原样;
误删可用 git checkout 或备份 zip 恢复。产物型部署 (Docker/桌面) 容器内没有
前端源码树, 一律 409 拒绝而不是静默无效。
"""
from __future__ import annotations
import re
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse
from app.config import settings
router = APIRouter(prefix="/api/pages-admin", tags=["pages-admin"])
# 与前端扩展注册表 ID 规则 (extensions/registry.ts ID_RE) 保持同形
_NS_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
# 模板目录是新建扩展的起点, 永不删除
_PROTECTED_NS = {"_template"}
# 只放行本模块生成的备份文件名, 兼作下载接口的穿越防御
_BACKUP_NAME_RE = re.compile(r"^[a-z0-9._-]+-\d{8}-\d{6}\.zip$")
def _custom_root() -> Path:
"""前端源码扩展根目录。
源码部署时 settings.static_dir = <项目根>/frontend/dist, 由此反推 src/custom;
产物部署 (Docker/桌面) 该路径不存在, 由调用方的 is_dir 检查转 409。
"""
return settings.static_dir.parent / "src" / "custom"
def _backup_root(request: Request) -> Path:
root = request.app.state.repo.store.data_dir / "backups" / "pages"
root.mkdir(parents=True, exist_ok=True)
return root
def _resolve_namespace_dir(namespace: str) -> Path:
root = _custom_root()
if not root.is_dir():
raise HTTPException(409, "当前部署没有前端源码目录, 仅源码部署支持删除扩展页")
if not _NS_RE.match(namespace) or namespace in _PROTECTED_NS:
raise HTTPException(400, f"非法的扩展命名空间: {namespace}")
ns_dir = (root / namespace).resolve()
if ns_dir.parent != root.resolve():
raise HTTPException(400, "非法路径")
if not ns_dir.is_dir():
raise HTTPException(404, f"扩展命名空间不存在: {namespace}")
return ns_dir
def _zip_dir(src: Path, dest: Path) -> None:
"""整目录打包, 归档内保留 <namespace>/ 前缀, 解压即得原目录结构。"""
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
for p in sorted(src.rglob("*")):
zf.write(p, p.relative_to(src.parent))
@router.delete("/source-extensions/{namespace}")
def delete_source_extension(namespace: str, request: Request) -> dict:
"""删除一个源码扩展页目录。先备份后删除, 备份失败不动源码。"""
ns_dir = _resolve_namespace_dir(namespace)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup = _backup_root(request) / f"{namespace}-{stamp}.zip"
_zip_dir(ns_dir, backup)
shutil.rmtree(ns_dir)
return {"deleted": namespace, "backup": backup.name}
@router.get("/backups/{filename}")
def download_backup(filename: str, request: Request) -> FileResponse:
"""下载删除备份 (attachment, 浏览器存到下载文件夹)。"""
if not _BACKUP_NAME_RE.match(filename):
raise HTTPException(404, "备份不存在")
root = _backup_root(request).resolve()
p = (root / filename).resolve()
if p.parent != root or not p.is_file():
raise HTTPException(404, "备份不存在")
return FileResponse(p, filename=filename, media_type="application/zip")