mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
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
This commit is contained in:
@@ -7,12 +7,15 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.services.fs_utils import atomic_write_text
|
||||
|
||||
router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
|
||||
|
||||
# url 嵌入型页面可指向的地址上限, 与前端 dashboard/layout.ts sanitizeExtUrl 同限
|
||||
_MAX_EMBED_URL_LEN = 500
|
||||
|
||||
|
||||
class AnalysisColumn(BaseModel):
|
||||
field: str
|
||||
@@ -31,12 +34,30 @@ class DefaultSort(BaseModel):
|
||||
order: Literal["asc", "desc"] = "desc"
|
||||
|
||||
|
||||
def _validate_template_binding(model: BaseModel) -> None:
|
||||
"""template 与 data_source / url 的绑定校验, AnalysisMenu 与 Upsert 共用。
|
||||
|
||||
- url 嵌入型: 只需合法 http(s) 地址, 不绑扩展数据源;
|
||||
- 数据型三种模板: data_source 仍必填非空 (保持既有不变量, 旧数据不受影响)。
|
||||
"""
|
||||
url = getattr(model, "url", None)
|
||||
if model.template == "url": # type: ignore[attr-defined]
|
||||
if not url or not str(url).startswith(("http://", "https://")):
|
||||
raise ValueError("url 模板必须提供 http(s) 地址")
|
||||
if len(str(url)) > _MAX_EMBED_URL_LEN:
|
||||
raise ValueError(f"url 长度不得超过 {_MAX_EMBED_URL_LEN}")
|
||||
elif not getattr(model, "data_source", ""):
|
||||
raise ValueError(f"{model.template} 模板必须配置扩展数据源")
|
||||
return model # mode="after" 校验器的返回值会替换模型, 必须回传
|
||||
|
||||
|
||||
class AnalysisMenu(BaseModel):
|
||||
id: str = Field(..., min_length=1, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
icon: str = "chart"
|
||||
data_source: str = Field(..., min_length=1)
|
||||
template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
|
||||
data_source: str = ""
|
||||
template: Literal["dimension_rank", "ranking", "table", "url"] = "dimension_rank"
|
||||
url: str | None = None
|
||||
dimension_field: str | None = None
|
||||
rank_field: str | None = None
|
||||
group_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
@@ -48,12 +69,15 @@ class AnalysisMenu(BaseModel):
|
||||
updated_at: str | None = None
|
||||
builtin: bool = False
|
||||
|
||||
_binding = model_validator(mode="after")(_validate_template_binding)
|
||||
|
||||
|
||||
class UpsertAnalysisMenu(BaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=64)
|
||||
icon: str = "chart"
|
||||
data_source: str = Field(..., min_length=1)
|
||||
template: Literal["dimension_rank", "ranking", "table"] = "dimension_rank"
|
||||
data_source: str = ""
|
||||
template: Literal["dimension_rank", "ranking", "table", "url"] = "dimension_rank"
|
||||
url: str | None = None
|
||||
dimension_field: str | None = None
|
||||
rank_field: str | None = None
|
||||
group_columns: list[AnalysisColumn] = Field(default_factory=list)
|
||||
@@ -62,6 +86,8 @@ class UpsertAnalysisMenu(BaseModel):
|
||||
visible: bool = True
|
||||
order: int = 0
|
||||
|
||||
_binding = model_validator(mode="after")(_validate_template_binding)
|
||||
|
||||
|
||||
class ReorderMenusReq(BaseModel):
|
||||
ids: list[str] = Field(..., min_length=1)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""运行时页面插件 API — 上传/清单回填/启停/删除/bundle 分发。
|
||||
"""运行时页面插件 API — 上传/清单回填/启停/删除/bundle 分发 + 源码导入。
|
||||
|
||||
分发端点位于 /api 前缀下, 自动受集中鉴权中间件保护: 浏览器同源动态
|
||||
import 会携带登录 cookie, 与页面会话同一访问边界。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -14,12 +15,15 @@ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import ext_source_import
|
||||
from app.services.ext_pages import PagePackMeta, PagePackStore
|
||||
|
||||
router = APIRouter(prefix="/api/ext-pages", tags=["ext-pages"])
|
||||
|
||||
# 单文件 ES 模块产物通常 < 1MB, 5MB 上限留足余量; 分块写防 OOM(同 ext_data)
|
||||
_MAX_BUNDLE_BYTES = 5 * 1024 * 1024
|
||||
# 源码文件本身很小; 限制上传体积只是防误传大文件的保险丝
|
||||
_MAX_SOURCE_BYTES = 1024 * 1024
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@@ -81,6 +85,83 @@ def list_page_packs(request: Request) -> list[PagePackMeta]:
|
||||
return _store(request).list()
|
||||
|
||||
|
||||
# ── 源码导入: .tsx 编译为标准页面插件 (仅源码/桌面部署可发起) ──
|
||||
|
||||
def _save_compiled(store: PagePackStore, pack_id: str, bundle: bytes) -> PagePackMeta:
|
||||
"""编译产物落库 (save_upload 吃 Path, 中转临时文件)。"""
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
tmp_path = tmp_dir / "bundle.js"
|
||||
tmp_path.write_bytes(bundle)
|
||||
return store.save_upload(pack_id, tmp_path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _derive_pack_id(raw: str, fallback: str) -> str:
|
||||
# 存储层 id 只允许字母数字下划线; 连字符等替换为下划线保留可读性
|
||||
pack_id = re.sub(r"[^a-zA-Z0-9_]", "_", raw or fallback)[:64]
|
||||
return pack_id or "imported_page"
|
||||
|
||||
|
||||
@router.get("/import-source/namespaces")
|
||||
def list_source_namespaces() -> list[str]:
|
||||
"""本项目 frontend/src/custom/ 下可导入的源码扩展清单。"""
|
||||
return ext_source_import.list_namespaces()
|
||||
|
||||
|
||||
@router.post("/import-source")
|
||||
async def import_source_pack(
|
||||
request: Request,
|
||||
file: Annotated[UploadFile, File()],
|
||||
id: Annotated[str, Form()] = "",
|
||||
) -> PagePackMeta:
|
||||
"""上传单个 .tsx 源码扩展, esbuild 编译+契约适配后入库为页面插件。"""
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix != ".tsx":
|
||||
raise HTTPException(400, "源码导入仅支持 .tsx 文件")
|
||||
tmp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
src = tmp_dir / "source.tsx"
|
||||
await _write_bundle_capped(file, src, _MAX_SOURCE_BYTES)
|
||||
if src.stat().st_size == 0:
|
||||
raise HTTPException(400, "源码文件为空")
|
||||
try:
|
||||
bundle, _summary = ext_source_import.compile_source(src, file.filename or "")
|
||||
except ext_source_import.SourceImportUnavailableError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
except ext_source_import.SourceCompileError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
pack_id = _derive_pack_id(id, Path(file.filename or "").stem)
|
||||
return _save_compiled(_store(request), pack_id, bundle)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
class ImportFromProjectBody(BaseModel):
|
||||
namespace: str
|
||||
id: str = ""
|
||||
|
||||
|
||||
@router.post("/import-source/from-project")
|
||||
def import_source_from_project(request: Request, body: ImportFromProjectBody) -> PagePackMeta:
|
||||
"""把本项目 frontend/src/custom/<ns>/extension.tsx 编译导入为页面插件。"""
|
||||
try:
|
||||
src = ext_source_import.namespace_source_path(body.namespace)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
try:
|
||||
bundle, _summary = ext_source_import.compile_source(src, body.namespace)
|
||||
except ext_source_import.SourceImportUnavailableError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
except ext_source_import.SourceCompileError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
pack_id = _derive_pack_id(body.id, body.namespace)
|
||||
return _save_compiled(_store(request), pack_id, bundle)
|
||||
|
||||
|
||||
@router.get("/{pack_id}/bundle.js")
|
||||
def get_page_pack_bundle(pack_id: str, request: Request) -> FileResponse:
|
||||
"""分发产物。no-store + ETag(sha256): 热更新产物后浏览器立即取新, 未变时可用缓存协商。"""
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""页面管理 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")
|
||||
@@ -31,6 +31,7 @@ from app.api import (
|
||||
mining,
|
||||
monitor_rules,
|
||||
overview,
|
||||
pages_admin,
|
||||
paper,
|
||||
pipeline,
|
||||
regime,
|
||||
@@ -614,6 +615,7 @@ app.include_router(mining.router)
|
||||
app.include_router(intraday.router)
|
||||
app.include_router(indices.router)
|
||||
app.include_router(overview.router)
|
||||
app.include_router(pages_admin.router)
|
||||
app.include_router(paper.router)
|
||||
app.include_router(abnormal.router)
|
||||
app.include_router(regime.router)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""源码扩展(.tsx)导入 — 宿主侧 esbuild 编译为自包含页面插件产物。
|
||||
|
||||
两个契约的鸿沟由生成的 shim 入口弥合:
|
||||
- 源码扩展 (FrontendExtensionModule) 导出 React 组件路由; 页面插件
|
||||
(PagePackExtension) 是命令式 mount(el, ctx)。shim 用 createRoot 渲染组件。
|
||||
- 打包后 react-query / react-router 是独立模块实例, 看不见宿主的
|
||||
Provider/Router — shim 自挂 QueryClientProvider + MemoryRouter。
|
||||
- navigation.icon 是 lucide 组件, 插件契约要求白名单字符串名 — 按
|
||||
displayName 精确匹配 runtime.tsx ICON_ALLOWLIST, 兜底 "Sparkles"。
|
||||
|
||||
仅源码/桌面部署可发起导入 (需要 frontend/node_modules 里的 esbuild);
|
||||
产物是标准页面插件, 全部署形态可用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 与 frontend/src/extensions/runtime.tsx ICON_ALLOWLIST 的键逐字一致 (大小写敏感)
|
||||
_ICON_ALLOWLIST = [
|
||||
"Activity", "BarChart3", "Bell", "BookOpen", "Brain", "Calendar",
|
||||
"Database", "Globe", "LineChart", "PieChart", "Shield", "Sparkles",
|
||||
"Star", "Table", "TrendingUp", "Zap",
|
||||
]
|
||||
|
||||
_NS_RE = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)*$")
|
||||
|
||||
# 与 ext_pages._MAX_BUNDLE_BYTES 同限: 编译产物也不能超上传上限
|
||||
_MAX_BUNDLE_BYTES = 5 * 1024 * 1024
|
||||
_COMPILE_TIMEOUT_S = 60.0
|
||||
|
||||
_SHIM_TEMPLATE = """\
|
||||
import { createElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import ext from __ENTRY__
|
||||
|
||||
const qc = new QueryClient()
|
||||
const ICON_ALLOWLIST = new Set(__ICONS__)
|
||||
function iconName(icon: unknown): string {
|
||||
const name = String((icon as { displayName?: string } | null)?.displayName ?? '')
|
||||
return ICON_ALLOWLIST.has(name) ? name : 'Sparkles'
|
||||
}
|
||||
const packId = String(ext.id).includes('.') ? String(ext.id) : `imported.${ext.id}`
|
||||
export default {
|
||||
id: packId,
|
||||
apiVersion: 1,
|
||||
routes: (ext.routes ?? []).map((r: { id: string; path: string; component: unknown }) => ({
|
||||
id: r.id,
|
||||
path: r.path,
|
||||
mount(el: HTMLElement) {
|
||||
const root = createRoot(el)
|
||||
root.render(
|
||||
createElement(QueryClientProvider, { client: qc },
|
||||
createElement(MemoryRouter, null, createElement(r.component as never))),
|
||||
)
|
||||
return () => root.unmount()
|
||||
},
|
||||
})),
|
||||
navigation: (ext.navigation ?? []).map((n: { id: string; routeId: string; label: string; icon?: unknown }) => ({
|
||||
id: n.id,
|
||||
routeId: n.routeId,
|
||||
label: n.label,
|
||||
icon: iconName(n.icon),
|
||||
})),
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class SourceImportUnavailableError(Exception):
|
||||
"""当前部署没有 Node/esbuild 工具链 (如 Docker 产物部署)。"""
|
||||
|
||||
|
||||
class SourceCompileError(Exception):
|
||||
"""esbuild 编译失败 (语法错误 / 无法解析的 import 等)。"""
|
||||
|
||||
|
||||
def frontend_root() -> Path:
|
||||
"""前端根目录 (源码部署 = <项目根>/frontend)。测试可 monkeypatch。"""
|
||||
return settings.static_dir.parent
|
||||
|
||||
|
||||
def custom_root() -> Path:
|
||||
return frontend_root() / "src" / "custom"
|
||||
|
||||
|
||||
def find_esbuild() -> Path | None:
|
||||
"""按常见包管理器布局发现 esbuild 二进制; 找不到再退回 PATH。
|
||||
|
||||
pnpm 布局下 esbuild 是 vite 的传递依赖, 不出现在 .bin —— 必须扫
|
||||
.pnpm/@esbuild+<ver>/node_modules/@esbuild/<platform>/bin/esbuild。
|
||||
"""
|
||||
root = frontend_root()
|
||||
candidates: list[Path] = [root / "node_modules" / ".bin" / "esbuild"]
|
||||
candidates.extend(sorted((root / "node_modules" / ".pnpm").glob(
|
||||
"@esbuild+*/node_modules/@esbuild/*/bin/esbuild")))
|
||||
candidates.extend(sorted((root / "node_modules" / "@esbuild").glob(
|
||||
"*/bin/esbuild")))
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
return c
|
||||
from shutil import which
|
||||
exe = which("esbuild")
|
||||
return Path(exe) if exe else None
|
||||
|
||||
|
||||
def namespace_source_path(namespace: str) -> Path:
|
||||
"""frontend/src/custom/<ns>/extension.tsx, 带命名空间与穿越校验。"""
|
||||
if not _NS_RE.match(namespace) or namespace == "_template":
|
||||
raise ValueError(f"非法的扩展命名空间: {namespace}")
|
||||
p = (custom_root() / namespace / "extension.tsx").resolve()
|
||||
if p.parent.parent != custom_root().resolve():
|
||||
raise ValueError("非法路径")
|
||||
if not p.is_file():
|
||||
raise ValueError(f"扩展命名空间不存在: {namespace}")
|
||||
return p
|
||||
|
||||
|
||||
def list_namespaces() -> list[str]:
|
||||
"""可导入的本项目源码扩展清单 (有 extension.tsx 的目录)。"""
|
||||
root = custom_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in root.iterdir()
|
||||
if d.is_dir() and (d / "extension.tsx").is_file()
|
||||
)
|
||||
|
||||
|
||||
def compile_source(source_path: Path, pack_id_hint: str) -> tuple[bytes, str]:
|
||||
"""esbuild 编译单个 .tsx 扩展为自包含页面插件产物。
|
||||
|
||||
编译临时目录放在 frontend/node_modules/.tsp-import-*/ 下: esbuild 按文件
|
||||
位置向上找 node_modules, 系统临时目录解析不到裸 import (react 等);
|
||||
node_modules 也是构建产物的归属地, Vite 不监视它。本项目内的源码保持
|
||||
原位编译 (保留其相对 import), 外部上传的源文件拷入临时目录。
|
||||
|
||||
Returns: (bundle_bytes, entry_summary) — entry_summary 用于日志/错误信息。
|
||||
Raises: SourceImportUnavailableError / SourceCompileError。
|
||||
"""
|
||||
esbuild = find_esbuild()
|
||||
if esbuild is None:
|
||||
raise SourceImportUnavailableError(
|
||||
"当前部署没有 esbuild 工具链, 源码导入仅源码/桌面部署可用")
|
||||
|
||||
root = frontend_root()
|
||||
try:
|
||||
source = source_path.resolve()
|
||||
except OSError as e:
|
||||
raise SourceCompileError(f"源文件不可读: {e}") from e
|
||||
under_frontend = root.resolve() in source.parents
|
||||
|
||||
tmp = root / "node_modules" / f".tsp-import-{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
if not under_frontend:
|
||||
copied = tmp / "source.tsx"
|
||||
copied.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
entry_source = copied
|
||||
else:
|
||||
entry_source = source
|
||||
shim = tmp / "shim.tsx"
|
||||
shim.write_text(
|
||||
_SHIM_TEMPLATE
|
||||
.replace("__ENTRY__", json.dumps(str(entry_source)))
|
||||
.replace("__ICONS__", json.dumps(_ICON_ALLOWLIST)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
out = tmp / "bundle.js"
|
||||
# 参数全部内部构造, esbuild 二进制来自本仓库 frontend/node_modules
|
||||
proc = subprocess.run(
|
||||
[
|
||||
str(esbuild), str(shim),
|
||||
"--bundle", "--format=esm", "--minify",
|
||||
"--jsx=automatic",
|
||||
f"--outfile={out}",
|
||||
f"--alias:@={root / 'src'}",
|
||||
"--define:process.env.NODE_ENV=\"production\"",
|
||||
"--loader:.css=empty",
|
||||
"--log-level=error",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_COMPILE_TIMEOUT_S,
|
||||
)
|
||||
if proc.returncode != 0 or not out.is_file():
|
||||
tail = (proc.stderr or proc.stdout or "").strip()[-800:]
|
||||
raise SourceCompileError(f"esbuild 编译失败: {tail or '无输出'}")
|
||||
data = out.read_bytes()
|
||||
if len(data) > _MAX_BUNDLE_BYTES:
|
||||
raise SourceCompileError(
|
||||
f"编译产物 {len(data) // 1024}KB 超过 {_MAX_BUNDLE_BYTES // (1024 * 1024)}MB 上限")
|
||||
return data, f"{source_path.name} ({len(data) // 1024}KB)"
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""url 嵌入型扩展页面: 模板绑定校验 (url 必填 http(s) / 数据型模板仍需 data_source)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import analysis
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(analysis.router)
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _url_body(**overrides) -> dict:
|
||||
body = {"label": "外部页面", "template": "url", "url": "https://example.com/embed"}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_url_template_roundtrip(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
r = c.post("/api/analysis-menus/my_embed", json=_url_body())
|
||||
assert r.status_code == 200, r.text
|
||||
saved = c.get("/api/analysis-menus/my_embed").json()
|
||||
assert saved["template"] == "url"
|
||||
assert saved["url"] == "https://example.com/embed"
|
||||
assert saved["label"] == "外部页面"
|
||||
|
||||
|
||||
def test_url_template_rejects_missing_bad_and_long_urls(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
assert c.post("/api/analysis-menus/a", json=_url_body(url=None)).status_code == 422
|
||||
assert c.post("/api/analysis-menus/b", json=_url_body(url="javascript:alert(1)")).status_code == 422
|
||||
assert c.post("/api/analysis-menus/d", json=_url_body(url="ftp://x/y")).status_code == 422
|
||||
assert c.post("/api/analysis-menus/e", json=_url_body(url="https://x/" + "a" * 600)).status_code == 422
|
||||
|
||||
|
||||
def test_data_templates_still_require_data_source(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
body = {"label": "排行", "template": "ranking", "data_source": ""}
|
||||
assert c.post("/api/analysis-menus/r1", json=body).status_code == 422
|
||||
# 正常数据型保存不受影响
|
||||
ok = c.post("/api/analysis-menus/r2", json={**body, "data_source": "ext_hy_ths"})
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["data_source"] == "ext_hy_ths"
|
||||
|
||||
|
||||
def test_url_template_ignores_data_source(tmp_path):
|
||||
"""url 模板不绑数据源: 缺省与传入都被接受, 保存值为空串。"""
|
||||
c = _client(tmp_path)
|
||||
r = c.post("/api/analysis-menus/u1", json=_url_body(data_source=""))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data_source"] == ""
|
||||
@@ -0,0 +1,109 @@
|
||||
"""源码导入(.tsx → 页面插件): esbuild 编译、契约 shim、端点校验与 409 降级。
|
||||
|
||||
CI 无 frontend/node_modules → find_esbuild 为 None → 整组跳过 (源码部署本机全跑)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import ext_pages
|
||||
from app.services import ext_source_import
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
ext_source_import.find_esbuild() is None,
|
||||
reason="当前环境无 esbuild (需要 frontend/node_modules), 源码导入仅源码/桌面部署可测",
|
||||
)
|
||||
|
||||
# 故意经由 @/ 别名引用宿主模块, 验证 alias 注入; 独立组件避免依赖 JSX 运行时差异
|
||||
_FIXTURE_TSX = """\
|
||||
import { createElement } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export function Demo() {
|
||||
return createElement('div', { className: cn('p-2', 'text-sm') }, 'demo')
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'zz_fixture_demo',
|
||||
apiVersion: 1 as const,
|
||||
routes: [{ id: 'demo', path: '/zz_fixture_demo', component: Demo }],
|
||||
navigation: [{ id: 'demo_nav', routeId: 'demo', label: '演示导入' }],
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _client(tmp_path: Path) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(ext_pages.router)
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path / "data"))
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_compile_fixture_bundles_host_alias_and_shim(tmp_path):
|
||||
src = tmp_path / "fixture.tsx"
|
||||
src.write_text(_FIXTURE_TSX, encoding="utf-8")
|
||||
data, summary = ext_source_import.compile_source(src, "fixture")
|
||||
assert len(data) > 1000
|
||||
# shim 的命名空间补点逻辑与源扩展 id 都应在产物里
|
||||
assert b"imported." in data
|
||||
assert b"zz_fixture_demo" in data
|
||||
# @/lib/cn 已被 alias 解析打进包 (而不是残留裸模块说明符导致浏览器无法加载)
|
||||
assert b"from '@/lib/cn'" not in data
|
||||
assert "fixture.tsx" in summary
|
||||
|
||||
|
||||
def test_upload_endpoint_compiles_and_stores(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
r = c.post(
|
||||
"/api/ext-pages/import-source",
|
||||
files={"file": ("demo_page.tsx", _FIXTURE_TSX.encode(), "text/plain")},
|
||||
data={"id": "zz_imported_demo"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
meta = r.json()
|
||||
assert meta["id"] == "zz_imported_demo"
|
||||
assert (tmp_path / "data" / "ext_pages" / "zz_imported_demo" / "bundle.js").is_file()
|
||||
|
||||
|
||||
def test_upload_rejects_non_tsx(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
r = c.post(
|
||||
"/api/ext-pages/import-source",
|
||||
files={"file": ("evil.js", b"export default {}", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_upload_without_esbuild_returns_409(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(ext_source_import, "find_esbuild", lambda: None)
|
||||
c = _client(tmp_path)
|
||||
r = c.post(
|
||||
"/api/ext-pages/import-source",
|
||||
files={"file": ("demo.tsx", _FIXTURE_TSX.encode(), "text/plain")},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "esbuild" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_from_project_validates_namespace(tmp_path):
|
||||
c = _client(tmp_path)
|
||||
assert c.post("/api/ext-pages/import-source/from-project", json={"namespace": "_template"}).status_code == 400
|
||||
assert c.post("/api/ext-pages/import-source/from-project", json={"namespace": "../x"}).status_code == 400
|
||||
assert c.post("/api/ext-pages/import-source/from-project", json={"namespace": "not_exist_ns"}).status_code == 400
|
||||
|
||||
|
||||
def test_from_project_compiles_real_namespace(tmp_path):
|
||||
"""真实编译一个小体量的本项目扩展, 验证完整链路 (真实前端源码树)。"""
|
||||
c = _client(tmp_path)
|
||||
r = c.post("/api/ext-pages/import-source/from-project", json={"namespace": "concept-analysis"})
|
||||
assert r.status_code == 200, r.text
|
||||
meta = r.json()
|
||||
assert meta["id"] == "concept_analysis"
|
||||
bundle = (tmp_path / "data" / "ext_pages" / "concept_analysis" / "bundle.js").read_bytes()
|
||||
assert b"concept-analysis" in bundle
|
||||
assert len(bundle) < 5 * 1024 * 1024
|
||||
@@ -0,0 +1,84 @@
|
||||
"""页面管理源码扩展删除: 校验拒绝路径、先备份后删除、备份下载穿越防御。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import pages_admin
|
||||
|
||||
|
||||
def _make_client(tmp_path: Path, monkeypatch, custom_root: Path) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(pages_admin.router)
|
||||
app.state.repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path / "data"))
|
||||
monkeypatch.setattr(pages_admin, "_custom_root", lambda: custom_root)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_custom(root: Path, ns: str = "zz_test_delete") -> Path:
|
||||
ns_dir = root / ns
|
||||
ns_dir.mkdir(parents=True)
|
||||
(ns_dir / "extension.tsx").write_text("export default { id: 'zz_test_delete' }", encoding="utf-8")
|
||||
(root / "_template").mkdir(exist_ok=True)
|
||||
return ns_dir
|
||||
|
||||
|
||||
def test_delete_creates_backup_then_removes_dir(tmp_path, monkeypatch):
|
||||
root = tmp_path / "frontend" / "src" / "custom"
|
||||
_seed_custom(root)
|
||||
client = _make_client(tmp_path, monkeypatch, root)
|
||||
|
||||
r = client.delete("/api/pages-admin/source-extensions/zz_test_delete")
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["deleted"] == "zz_test_delete"
|
||||
assert body["backup"].startswith("zz_test_delete-") and body["backup"].endswith(".zip")
|
||||
# 目录已删, 备份存在且包含带命名空间前缀的源文件
|
||||
assert not (root / "zz_test_delete").exists()
|
||||
backup = tmp_path / "data" / "backups" / "pages" / body["backup"]
|
||||
assert backup.is_file()
|
||||
with zipfile.ZipFile(backup) as zf:
|
||||
assert "zz_test_delete/extension.tsx" in zf.namelist()
|
||||
|
||||
|
||||
def test_rejects_template_illegal_and_missing(tmp_path, monkeypatch):
|
||||
root = tmp_path / "frontend" / "src" / "custom"
|
||||
_seed_custom(root)
|
||||
client = _make_client(tmp_path, monkeypatch, root)
|
||||
|
||||
assert client.delete("/api/pages-admin/source-extensions/_template").status_code == 400
|
||||
# 编码斜杠被路由层判 404、正则不匹配判 400 — 两种都是拒绝, 关键是不触发删除
|
||||
assert client.delete("/api/pages-admin/source-extensions/..%2Fetc").status_code in (400, 404)
|
||||
assert client.delete("/api/pages-admin/source-extensions/Has-Caps").status_code == 400
|
||||
assert client.delete("/api/pages-admin/source-extensions/not_exist").status_code == 404
|
||||
# 模板目录必须原样保留
|
||||
assert (root / "_template").is_dir()
|
||||
|
||||
|
||||
def test_rejects_when_no_frontend_source_tree(tmp_path, monkeypatch):
|
||||
client = _make_client(tmp_path, monkeypatch, tmp_path / "no-such-root")
|
||||
r = client.delete("/api/pages-admin/source-extensions/zz_test_delete")
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_backup_download_serves_only_own_archives(tmp_path, monkeypatch):
|
||||
root = tmp_path / "frontend" / "src" / "custom"
|
||||
_seed_custom(root)
|
||||
client = _make_client(tmp_path, monkeypatch, root)
|
||||
backup_name = client.delete("/api/pages-admin/source-extensions/zz_test_delete").json()["backup"]
|
||||
|
||||
ok = client.get(f"/api/pages-admin/backups/{backup_name}")
|
||||
assert ok.status_code == 200
|
||||
assert ok.headers["content-disposition"].startswith("attachment")
|
||||
assert zipfile.ZipFile(io.BytesIO(ok.content)).namelist()
|
||||
|
||||
# 穿越与非本模块命名的文件一律 404
|
||||
assert client.get("/api/pages-admin/backups/evil.zip").status_code == 404
|
||||
assert client.get("/api/pages-admin/backups/..%2Fsecrets.zip").status_code == 404
|
||||
assert client.get("/api/pages-admin/backups/zz-20990101-000000.zip").status_code == 404
|
||||
+11
-1
@@ -161,9 +161,19 @@
|
||||
|
||||
## 🧰 数据与扩展
|
||||
|
||||
### 页面管理(菜单 / 扩展页面 / 页面插件)
|
||||
|
||||
「设置 → 页面管理」统一管理三类页面能力, 内部三个区块: **菜单与页面**(侧栏排序/显示隐藏、源码扩展页删除)、**扩展页面**(声明式分析页 CRUD)、**页面插件**(上传/启停/删除)。原「菜单设置 / 扩展页面 / 页面插件」三个设置 tab 已并入, 旧链接 `?tab=menus|ext-pages|page-packs` 自动重定向。
|
||||
|
||||
**扩展页面**支持四种模板: 维度热度榜 / 指标排名榜 / 明细表(基于扩展数据源), 以及 **URL 嵌入**(只填菜单名 + http(s) 网址, 生成以安全 iframe 内嵌外部页面的侧栏页面 — 复用看板外链的安全约定: 仅 http(s)、拒嵌本站、sandbox 白名单、X-Frame-Options 拒嵌时提供新窗口兜底)。注意 https 面板嵌入 http 地址会被浏览器拦截。
|
||||
|
||||
**源码扩展页删除**: 菜单与页面区块中, 源码扩展(`frontend/src/custom/<ns>/`)行可物理删除 — 后端先自动打包备份 zip 到 `data/backups/pages/`(界面提供下载), 再删除目录, 刷新/重新构建后该页面从菜单与路由消失; 误删可解压备份或 `git checkout -- frontend/src/custom/<ns>` 恢复。仅源码部署可用, Docker/桌面产物部署(容器内无前端源码树)返回 409 明确拒绝。
|
||||
|
||||
**源码导入**: 页面插件区块支持直接导入 `.tsx` 源码扩展 — 上传单文件或从本项目 `frontend/src/custom/` 清单选择, 后端用项目自带的 esbuild 自动编译并适配契约(组件路由→命令式挂载、图标→白名单), 产物即标准页面插件, 免重新构建。已知边界: 源码注册的插槽被跳过、页面内路由参数读到空值、数据缓存独立于宿主、包内捆绑的是导入时的宿主库副本(面板升级后重新导入即可刷新); 仅源码/桌面部署可发起导入(需 Node 工具链), 已导入的包全部署形态可用。
|
||||
|
||||
### 页面插件(运行时扩展页面)
|
||||
|
||||
页面可以打包成**单文件 ES 模块产物**(`frontend/page-pack-template` 一键构建),在「设置 → 页面插件」上传即挂载为完整页面 — 路由、侧栏入口、菜单管理自动注册,**宿主无需重新构建**;本地 dev / 生产 / 桌面端一致生效。上传时即时试载校验契约(清单格式、路由冲突),坏包停用并显示原因;启停删除随时管理,刷新后生效。源码级扩展(`frontend/src/custom/`)与声明式分析页面仍是随构建的另外两条扩展路径。契约与安全边界见 [page-sdk.md](./page-sdk.md)。
|
||||
页面可以打包成**单文件 ES 模块产物**(`frontend/page-pack-template` 一键构建),在「设置 → 页面管理 → 页面插件」上传即挂载为完整页面 — 路由、侧栏入口、菜单管理自动注册,**宿主无需重新构建**;本地 dev / 生产 / 桌面端一致生效。上传时即时试载校验契约(清单格式、路由冲突),坏包停用并显示原因;启停删除随时管理,刷新后生效。源码级扩展(`frontend/src/custom/`)与声明式分析页面仍是随构建的另外两条扩展路径。契约与安全边界见 [page-sdk.md](./page-sdk.md)。
|
||||
|
||||
### 数据源插件化
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* URL 嵌入型扩展页面 — 整页内嵌外部 http(s) 页面。
|
||||
*
|
||||
* 安全约定与看板 ExternalLinkWidget 一致 (配合 dashboard/layout.ts sanitizeExtUrl):
|
||||
* - URL 仅 http(s) 且拒绝与本站同源 — 同源内容配 allow-scripts+allow-same-origin
|
||||
* 等于绕过 sandbox, 一律不嵌;
|
||||
* - sandbox 显式放行 脚本/同源(目标站自身存储)/弹窗/表单, 其余默认禁;
|
||||
* - referrerPolicy=no-referrer; 部分站点以 X-Frame-Options/CSP 拒绝嵌入 →
|
||||
* 白屏兜底: 常驻「新窗口打开」+ 提示文案。
|
||||
*/
|
||||
import { ExternalLink, Link2, ShieldAlert } from 'lucide-react'
|
||||
import { sanitizeExtUrl } from '@/components/dashboard/layout'
|
||||
|
||||
export function ExtUrlEmbed({ label, url }: { label: string; url: string }) {
|
||||
const safeUrl = sanitizeExtUrl(url)
|
||||
|
||||
if (!safeUrl) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-card border border-border bg-surface p-8 text-center">
|
||||
<ShieldAlert className="mx-auto h-6 w-6 text-accent" />
|
||||
<h2 className="mt-3 text-lg font-semibold text-foreground">无法嵌入该地址</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-secondary">
|
||||
嵌入地址仅支持 http(s) 且不能是本站自身。请到「设置 → 页面管理 → 扩展页面」修改该页面的 URL。
|
||||
</p>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted">{url}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let host = ''
|
||||
try { host = new URL(safeUrl).hostname } catch { /* sanitizeExtUrl 已保证可解析 */ }
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col overflow-hidden rounded-card border border-border bg-surface">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border/60 px-3 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate" title={label}>{label}</span>
|
||||
{host && <span className="shrink-0 truncate font-mono text-[10px] text-muted">{host}</span>}
|
||||
</span>
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-btn px-2 py-1 text-xs text-secondary transition-colors hover:bg-elevated hover:text-foreground"
|
||||
title="在新窗口打开"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
<iframe
|
||||
src={safeUrl}
|
||||
title={label}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
/>
|
||||
<div className="shrink-0 px-3 py-1 text-[10px] text-muted/60">
|
||||
若长时间空白, 该站点可能禁止被嵌入 (X-Frame-Options), 请用右上角新窗口打开
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+36
-1
@@ -3491,6 +3491,39 @@ export const api = {
|
||||
extPagesDelete: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/ext-pages/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
|
||||
// ===== 源码导入: .tsx → 后端 esbuild 编译+契约适配 → 标准页面插件 =====
|
||||
// 编译可能耗时数秒 (全依赖自包含打包), 走计算型超时; 仅源码/桌面部署可用 (409)
|
||||
extPagesImportSource: (file: File, id?: string) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (id) fd.append('id', id)
|
||||
return request<RuntimePagePack>('/api/ext-pages/import-source', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
timeoutMs: COMPUTE_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
},
|
||||
|
||||
extPagesImportFromProject: (namespace: string) =>
|
||||
request<RuntimePagePack>('/api/ext-pages/import-source/from-project', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ namespace }),
|
||||
timeoutMs: COMPUTE_REQUEST_TIMEOUT_MS,
|
||||
}),
|
||||
|
||||
extPagesSourceNamespaces: () =>
|
||||
request<string[]>('/api/ext-pages/import-source/namespaces'),
|
||||
|
||||
// ===== 页面管理 — 源码扩展页删除 =====
|
||||
// 物理删除 frontend/src/custom/<ns>/; 后端先备份 zip 到 data/backups/pages/ 再删,
|
||||
// 返回备份文件名。仅源码部署可用 (Docker/桌面产物部署后端 409)。
|
||||
pageAdminDeleteSourceExtension: (namespace: string) =>
|
||||
request<{ deleted: string; backup: string }>(`/api/pages-admin/source-extensions/${encodeURIComponent(namespace)}`, { method: 'DELETE' }),
|
||||
|
||||
// 备份下载走浏览器原生下载 (同源 /api, dev 由 Vite 代理), 存到用户下载文件夹
|
||||
pageAdminBackupUrl: (filename: string) =>
|
||||
`/api/pages-admin/backups/${encodeURIComponent(filename)}`,
|
||||
|
||||
analysisMenu: (id: string) =>
|
||||
request<AnalysisMenu>(`/api/analysis-menus/${encodeURIComponent(id)}`),
|
||||
|
||||
@@ -4595,7 +4628,9 @@ export interface AnalysisMenu {
|
||||
label: string
|
||||
icon: string
|
||||
data_source: string
|
||||
template: 'dimension_rank' | 'ranking' | 'table'
|
||||
template: 'dimension_rank' | 'ranking' | 'table' | 'url'
|
||||
/** url 嵌入型模板: 内嵌的 http(s) 地址 (仅 template='url' 有值) */
|
||||
url?: string | null
|
||||
dimension_field?: string | null
|
||||
rank_field?: string | null
|
||||
group_columns: AnalysisColumn[]
|
||||
|
||||
@@ -80,6 +80,7 @@ export const QK = {
|
||||
analysisMenus: ['analysis-menus'] as const,
|
||||
analysisMenu: (id: string) => ['analysis-menu', id] as const,
|
||||
extPages: ['ext-pages'] as const,
|
||||
extPagesSourceNamespaces: ['ext-pages', 'source-namespaces'] as const,
|
||||
|
||||
// Kline
|
||||
kline: (symbol: string, start: string, end: string, extColumns?: string) =>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { ExtDimensionAnalysis } from '@/components/ExtDimensionAnalysis'
|
||||
import { ExtUrlEmbed } from '@/components/ExtUrlEmbed'
|
||||
|
||||
export function AnalysisDetail() {
|
||||
const { menuId } = useParams()
|
||||
// url 嵌入型提前分发; 数据型模板回落 ExtDimensionAnalysis (其内部自行按 menuId 取菜单)
|
||||
const { data } = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
|
||||
const menu = data?.items.find(m => m.id === menuId)
|
||||
if (menu?.template === 'url') {
|
||||
return <ExtUrlEmbed label={menu.label} url={menu.url ?? ''} />
|
||||
}
|
||||
return <ExtDimensionAnalysis menuId={menuId} />
|
||||
}
|
||||
|
||||
@@ -3,17 +3,15 @@
|
||||
*
|
||||
* 通过 URL query param ?tab=xxx 同步 Tab 状态。
|
||||
*/
|
||||
import { Suspense, lazy, useState, type ComponentType } from 'react'
|
||||
import { Suspense, lazy, useEffect, useState, type ComponentType } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { BarChart3, Database, KeyRound, Package, Radio, SlidersHorizontal, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info } from 'lucide-react'
|
||||
import { Database, KeyRound, LayoutGrid, Radio, Sparkles, Settings2, PanelLeftClose, PanelLeftOpen, Clock3, Info } 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 SettingsMonitoringPanel = lazy(() => import('./settings/Monitoring').then(m => ({ default: m.SettingsMonitoringPanel })))
|
||||
const SettingsExtPagesPanel = lazy(() => import('./settings/ExtPages').then(m => ({ default: m.SettingsExtPagesPanel })))
|
||||
const SettingsPagePacksPanel = lazy(() => import('./settings/PagePacks').then(m => ({ default: m.SettingsPagePacksPanel })))
|
||||
const SettingsMenuSettingsPanel = lazy(() => import('./settings/MenuSettings').then(m => ({ default: m.SettingsMenuSettingsPanel })))
|
||||
const SettingsPageAdminPanel = lazy(() => import('./settings/PageAdmin').then(m => ({ default: m.SettingsPageAdminPanel })))
|
||||
const SettingsTimeoutPanel = lazy(() => import('./settings/Timeout').then(m => ({ default: m.SettingsTimeoutPanel })))
|
||||
const SettingsSystemPanel = lazy(() => import('./settings/System').then(m => ({ default: m.SettingsSystemPanel })))
|
||||
const SettingsAboutPanel = lazy(() => import('./settings/About').then(m => ({ default: m.SettingsAboutPanel })))
|
||||
@@ -34,24 +32,40 @@ type TabDef = {
|
||||
const TABS: readonly TabDef[] = [
|
||||
{ key: 'data-sources', label: '数据源', icon: Database, panel: SettingsDataSourcesPanel },
|
||||
{ key: 'ai', label: 'AI 设置', icon: Sparkles, panel: SettingsAIPanel },
|
||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||
{ key: 'ext-pages', label: '扩展页面', icon: BarChart3, panel: SettingsExtPagesPanel },
|
||||
{ key: 'page-packs', label: '页面插件', icon: Package, panel: SettingsPagePacksPanel },
|
||||
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||
{ key: 'menus', label: '菜单设置', icon: SlidersHorizontal, panel: SettingsMenuSettingsPanel },
|
||||
{ key: 'api-tokens', label: '开放接口', icon: KeyRound, panel: SettingsApiTokensPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
{ key: 'about', label: '关于', icon: Info, panel: SettingsAboutPanel },
|
||||
{ key: 'monitoring', label: '实时监控', icon: Radio, panel: SettingsMonitoringPanel },
|
||||
{ key: 'pages', label: '页面管理', icon: LayoutGrid, panel: SettingsPageAdminPanel },
|
||||
{ key: 'timeout', label: '网络设置', icon: Clock3, panel: SettingsTimeoutPanel },
|
||||
{ key: 'api-tokens', label: '开放接口', icon: KeyRound, panel: SettingsApiTokensPanel },
|
||||
{ key: 'system', label: '系统设置', icon: Settings2, panel: SettingsSystemPanel },
|
||||
{ key: 'about', label: '关于', icon: Info, panel: SettingsAboutPanel },
|
||||
]
|
||||
|
||||
// 「页面管理」归拢前的三个 tab key → 深链子区块, 历史链接零失效
|
||||
const LEGACY_PAGE_TAB_SECTIONS: Record<string, string> = {
|
||||
menus: 'menus',
|
||||
'ext-pages': 'ext-pages',
|
||||
'page-packs': 'page-packs',
|
||||
}
|
||||
|
||||
type TabKey = (typeof TABS)[number]['key']
|
||||
|
||||
export function Settings() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const tabParam = searchParams.get('tab') as TabKey | null
|
||||
const activeTab = TABS.find((t) => t.key === tabParam) ?? TABS[0]
|
||||
// 旧 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 highlight = searchParams.get('highlight') ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!legacySection) return
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('tab', 'pages')
|
||||
next.set('section', legacySection)
|
||||
setSearchParams(next, { replace: true })
|
||||
}, [legacySection, searchParams, setSearchParams])
|
||||
|
||||
// 设置菜单收起状态 — 持久化到 localStorage
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem('tf-settings-nav-collapsed') === '1' } catch { return false }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ExternalLink, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
|
||||
import { api, type AnalysisColumn, type AnalysisMenu, type ExtDataConfig, type ExtDataField } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
import { sanitizeExtUrl } from '@/components/dashboard/layout'
|
||||
|
||||
function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
|
||||
return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
|
||||
@@ -43,7 +44,8 @@ export function SettingsExtPagesPanel() {
|
||||
const [id, setId] = useState('')
|
||||
const [label, setLabel] = useState('')
|
||||
const [dataSource, setDataSource] = useState('')
|
||||
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
|
||||
const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table' | 'url'>('dimension_rank')
|
||||
const [embedUrl, setEmbedUrl] = useState('')
|
||||
const [dimensionField, setDimensionField] = useState('')
|
||||
const [rankField, setRankField] = useState('')
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
|
||||
@@ -60,6 +62,7 @@ export function SettingsExtPagesPanel() {
|
||||
setLabel('')
|
||||
setDataSource(cfg?.id ?? '')
|
||||
setTemplate('dimension_rank')
|
||||
setEmbedUrl('')
|
||||
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
|
||||
setRankField('')
|
||||
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
|
||||
@@ -73,6 +76,7 @@ export function SettingsExtPagesPanel() {
|
||||
setLabel(menu.label)
|
||||
setDataSource(menu.data_source)
|
||||
setTemplate(menu.template)
|
||||
setEmbedUrl(menu.url ?? '')
|
||||
setDimensionField(menu.dimension_field ?? firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
|
||||
setRankField(menu.rank_field ?? '')
|
||||
setSelectedColumns(menu.detail_columns.map(c => c.field))
|
||||
@@ -82,10 +86,31 @@ export function SettingsExtPagesPanel() {
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const cfg = activeConfig
|
||||
if (!cfg) throw new Error('请选择扩展数据源')
|
||||
if (!id.trim()) throw new Error('请输入菜单标识')
|
||||
if (!label.trim()) throw new Error('请输入菜单名称')
|
||||
|
||||
// url 嵌入型: 不绑数据源, 只需消毒后的地址 (与渲染端 ExtUrlEmbed 同一 sanitize)
|
||||
if (template === 'url') {
|
||||
const safe = sanitizeExtUrl(embedUrl)
|
||||
if (!safe) throw new Error('嵌入地址无效: 仅支持 http(s)、长度 ≤500, 且不能嵌入本站自身')
|
||||
return api.analysisMenuSave(id.trim(), {
|
||||
label: label.trim(),
|
||||
icon: 'link',
|
||||
data_source: '',
|
||||
template: 'url',
|
||||
url: safe,
|
||||
dimension_field: null,
|
||||
rank_field: null,
|
||||
group_columns: [],
|
||||
detail_columns: [],
|
||||
default_sort: null,
|
||||
visible: editingMenu?.visible ?? true,
|
||||
order: editingMenu?.order ?? menuItems.length + 100,
|
||||
})
|
||||
}
|
||||
|
||||
const cfg = activeConfig
|
||||
if (!cfg) throw new Error('请选择扩展数据源')
|
||||
if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
|
||||
if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
|
||||
|
||||
@@ -137,6 +162,7 @@ export function SettingsExtPagesPanel() {
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">把扩展数据配置成左侧分析菜单</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
|
||||
选择扩展数据源、分析模板、分组字段和列表列后,系统会生成一个可访问的动态分析页面。
|
||||
也可以直接填网址创建 URL 嵌入页;要导入 .tsx 源码页面请到「页面插件」区块的「导入源码」。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -176,22 +202,35 @@ export function SettingsExtPagesPanel() {
|
||||
<span className="text-[11px] text-muted">菜单名称</span>
|
||||
<input value={label} onChange={e => setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">扩展数据源</span>
|
||||
<select
|
||||
value={dataSource || activeConfig?.id || ''}
|
||||
onChange={e => {
|
||||
const cfg = configs.find(c => c.id === e.target.value)
|
||||
setDataSource(e.target.value)
|
||||
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
|
||||
setRankField('')
|
||||
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
|
||||
}}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
|
||||
>
|
||||
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{template === 'url' ? (
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">嵌入地址</span>
|
||||
<input
|
||||
value={embedUrl}
|
||||
onChange={e => setEmbedUrl(e.target.value)}
|
||||
placeholder="https://example.com/page"
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
|
||||
/>
|
||||
<span className="block text-[10px] text-muted/70">仅 http(s)、≤500 字符,不支持嵌入本站;部分站点可拒绝被嵌入</span>
|
||||
</label>
|
||||
) : (
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-[11px] text-muted">扩展数据源</span>
|
||||
<select
|
||||
value={dataSource || activeConfig?.id || ''}
|
||||
onChange={e => {
|
||||
const cfg = configs.find(c => c.id === e.target.value)
|
||||
setDataSource(e.target.value)
|
||||
setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
|
||||
setRankField('')
|
||||
setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
|
||||
}}
|
||||
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
|
||||
>
|
||||
{configs.map(cfg => <option key={cfg.id} value={cfg.id}>{cfg.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
@@ -201,6 +240,7 @@ export function SettingsExtPagesPanel() {
|
||||
<option value="dimension_rank">维度热度榜</option>
|
||||
<option value="ranking">指标排名榜</option>
|
||||
<option value="table">明细表</option>
|
||||
<option value="url">URL 嵌入</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
@@ -219,23 +259,25 @@ export function SettingsExtPagesPanel() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] text-muted mb-2">列表列配置</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
|
||||
const active = selectedColumns.includes(f.name)
|
||||
return (
|
||||
<button
|
||||
key={f.name}
|
||||
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
|
||||
>
|
||||
{f.label || f.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{template !== 'url' && (
|
||||
<div>
|
||||
<div className="text-[11px] text-muted mb-2">列表列配置</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
|
||||
const active = selectedColumns.includes(f.name)
|
||||
return (
|
||||
<button
|
||||
key={f.name}
|
||||
onClick={() => setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
|
||||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
|
||||
>
|
||||
{f.label || f.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-btn border border-danger/30 bg-danger/5 px-3 py-2 text-xs text-danger">{error}</div>}
|
||||
|
||||
@@ -272,10 +314,16 @@ export function SettingsExtPagesPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-[11px] text-secondary">
|
||||
<div>数据源:<span className="font-mono text-muted">{menu.data_source}</span></div>
|
||||
{menu.template === 'url' ? (
|
||||
<div className="break-all">地址:<span className="font-mono text-muted">{menu.url}</span></div>
|
||||
) : (
|
||||
<>
|
||||
<div>数据源:<span className="font-mono text-muted">{menu.data_source}</span></div>
|
||||
{menu.dimension_field && <div>分组字段:{menu.dimension_field}</div>}
|
||||
<div>列表列:{menu.detail_columns.length} 个</div>
|
||||
</>
|
||||
)}
|
||||
<div>模板:{menu.template}</div>
|
||||
{menu.dimension_field && <div>分组字段:{menu.dimension_field}</div>}
|
||||
<div>列表列:{menu.detail_columns.length} 个</div>
|
||||
</div>
|
||||
<Link to={`/analysis/${menu.id}`} className="mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-btn border border-border bg-elevated px-3 py-1.5 text-xs text-foreground hover:bg-border/30 transition-colors">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
|
||||
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell, Trash2 } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '@/lib/api'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
import { getFrontendExtensionNavigation } from '@/extensions/registry'
|
||||
@@ -30,6 +31,8 @@ interface NavEntry {
|
||||
label: string
|
||||
type: 'builtin' | 'analysis' | 'extension'
|
||||
visible: boolean
|
||||
/** 源码扩展命名空间 (frontend/src/custom/<ns>/), 仅 type=extension 有值; 物理删除的凭据 */
|
||||
extensionId?: string
|
||||
}
|
||||
|
||||
// 核心页清单; nav_order 未保存时的完整默认顺序以 lib/navOrder 基准为准
|
||||
@@ -77,12 +80,14 @@ const archOf = (entry: NavEntry): { kind: ArchKind; reason: string } =>
|
||||
|
||||
// ── Sortable row ──
|
||||
|
||||
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
|
||||
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge, onRemove }: {
|
||||
entry: NavEntry
|
||||
hidden: boolean
|
||||
onToggleHidden: (id: string) => void
|
||||
badgeEnabled?: boolean
|
||||
onToggleBadge?: (id: string) => void
|
||||
/** 删除源码扩展页 (物理删除 frontend/src/custom/<extensionId>/, 先备份) */
|
||||
onRemove?: (entry: NavEntry) => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -104,7 +109,7 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/60 px-4 py-2.5 transition-colors last:border-b-0 hover:bg-elevated/40 ${
|
||||
className={`group grid grid-cols-[2.5rem_1fr_4.5rem_3rem_4.5rem_3rem] items-center border-b border-border/60 px-4 py-2.5 transition-colors last:border-b-0 hover:bg-elevated/40 ${
|
||||
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
|
||||
} ${hidden ? 'opacity-50' : ''}`}
|
||||
>
|
||||
@@ -157,10 +162,10 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
||||
{hidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center gap-1">
|
||||
{entry.type === 'analysis' ? (
|
||||
<Link
|
||||
to={`/settings?tab=ext-pages`}
|
||||
to={`/settings?tab=pages§ion=ext-pages`}
|
||||
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
|
||||
title="编辑扩展页面"
|
||||
>
|
||||
@@ -175,6 +180,16 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
{onRemove && entry.extensionId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(entry)}
|
||||
className="rounded p-1 text-muted hover:bg-danger/10 hover:text-danger transition-colors"
|
||||
title={`删除源码扩展页 (物理删除 frontend/src/custom/${entry.extensionId}/, 自动备份)`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 第 6 列: 徽标开关 (仅监控中心) */}
|
||||
<div className="flex justify-center">
|
||||
@@ -212,12 +227,18 @@ export function SettingsMenuSettingsPanel() {
|
||||
|
||||
// 扩展导航 (frontend/src/custom/*/extension.tsx 注册): id 取路由 path,
|
||||
// 与 Layout 侧边栏 nav_order / nav_hidden 按 to 匹配的口径一致。
|
||||
const extensionEntries: NavEntry[] = getFrontendExtensionNavigation().map(item => ({
|
||||
id: item.route.path,
|
||||
label: item.label,
|
||||
type: 'extension' as const,
|
||||
visible: true,
|
||||
}))
|
||||
// removedNs: 本次会话已物理删除的命名空间 — 注册表是启动期冻结快照,
|
||||
// 刷新页面前需本地过滤, 否则已删除的行会残留。
|
||||
const [removedNs, setRemovedNs] = useState<Set<string>>(new Set())
|
||||
const extensionEntries: NavEntry[] = getFrontendExtensionNavigation()
|
||||
.filter(item => !removedNs.has(item.extensionId))
|
||||
.map(item => ({
|
||||
id: item.route.path,
|
||||
label: item.label,
|
||||
type: 'extension' as const,
|
||||
visible: true,
|
||||
extensionId: item.extensionId,
|
||||
}))
|
||||
|
||||
const allEntries = useMemo(() => {
|
||||
const saved = prefs?.nav_order ?? []
|
||||
@@ -302,6 +323,25 @@ export function SettingsMenuSettingsPanel() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
|
||||
})
|
||||
|
||||
// 删除源码扩展页: 双重确认 → 后端先备份 zip 再删目录 → 本地移行 + 提示刷新
|
||||
const [deletedBackup, setDeletedBackup] = useState<{ ns: string; backup: string } | null>(null)
|
||||
const deleteExtension = useMutation({
|
||||
mutationFn: (ns: string) => api.pageAdminDeleteSourceExtension(ns),
|
||||
onSuccess: (r) => {
|
||||
setRemovedNs(prev => new Set(prev).add(r.deleted))
|
||||
setDeletedBackup({ ns: r.deleted, backup: r.backup })
|
||||
toast(`已删除 ${r.deleted} (已备份), 刷新页面后从菜单与路由消失`, 'success')
|
||||
},
|
||||
onError: (e: Error) => toast(e.message || '删除失败', 'error'),
|
||||
})
|
||||
const handleRemoveExtension = (entry: NavEntry) => {
|
||||
const ns = entry.extensionId
|
||||
if (!ns) return
|
||||
if (!confirm(`删除源码扩展页「${entry.label}」?\n\n将物理删除 frontend/src/custom/${ns}/ 目录, 刷新后该页面从菜单和路由中消失。`)) return
|
||||
if (!confirm(`二次确认: 删除前会自动备份为 zip (可下载恢复)。确定删除 ${ns}?`)) return
|
||||
deleteExtension.mutate(ns)
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -360,7 +400,7 @@ export function SettingsMenuSettingsPanel() {
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface overflow-hidden">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border bg-elevated/40 px-4 py-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-muted">
|
||||
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_4.5rem_3rem] items-center border-b border-border bg-elevated/40 px-4 py-1.5 text-[10px] font-medium uppercase tracking-[0.06em] text-muted">
|
||||
<div />
|
||||
<div>菜单 · 归属</div>
|
||||
<div>类型</div>
|
||||
@@ -386,6 +426,7 @@ export function SettingsMenuSettingsPanel() {
|
||||
onToggleHidden={toggleHidden}
|
||||
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
|
||||
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
|
||||
onRemove={handleRemoveExtension}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -395,6 +436,22 @@ export function SettingsMenuSettingsPanel() {
|
||||
<div className="px-5 py-10 text-center text-sm text-muted">正在加载菜单...</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{deletedBackup && (
|
||||
<div className="rounded-card border border-border bg-elevated/40 p-4 text-sm text-secondary flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span>
|
||||
已删除源码扩展页 <span className="font-mono text-foreground">{deletedBackup.ns}</span>,刷新页面后生效。
|
||||
</span>
|
||||
<a
|
||||
href={api.pageAdminBackupUrl(deletedBackup.backup)}
|
||||
download
|
||||
className="rounded border border-border px-2 py-1 text-xs text-accent hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
下载备份 {deletedBackup.backup}
|
||||
</a>
|
||||
<span className="text-xs text-muted">备份同时保存在服务端 data/backups/pages/,误删可解压恢复或 git checkout</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 页面管理 — 原「菜单设置 / 扩展页面 / 页面插件」三个设置 tab 的三合一外壳。
|
||||
*
|
||||
* ?section=<menus|ext-pages|page-packs> 深链定位子区块; Settings.tsx 会把旧
|
||||
* ?tab=menus/ext-pages/page-packs 链接重写为 ?tab=pages§ion=<旧值>, 历史
|
||||
* 链接与 router 内的 /analysis 重定向均不失效。
|
||||
*/
|
||||
import { 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']
|
||||
|
||||
export function SettingsPageAdminPanel() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const sectionParam = searchParams.get('section') as SectionKey | null
|
||||
const section: SectionKey = SECTIONS.some(s => s.key === sectionParam) ? sectionParam! : 'menus'
|
||||
|
||||
const switchSection = (key: SectionKey) => {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.set('section', key)
|
||||
setSearchParams(next, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SECTIONS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => switchSection(s.key)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
section === s.key
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-border text-secondary hover:bg-elevated/60',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{section === 'menus' && <SettingsMenuSettingsPanel />}
|
||||
{section === 'ext-pages' && <SettingsExtPagesPanel />}
|
||||
{section === 'page-packs' && <SettingsPagePacksPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Package, Trash2, Upload, AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
import { Package, Trash2, Upload, AlertTriangle, CheckCircle2, FileCode, FolderDown } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { toast } from '@/components/Toast'
|
||||
@@ -33,6 +33,11 @@ export function SettingsPagePacksPanel() {
|
||||
const [idInput, setIdInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [busyImport, setBusyImport] = useState(false)
|
||||
const [importNs, setImportNs] = useState('')
|
||||
const importFileRef = useRef<HTMLInputElement>(null)
|
||||
// 本项目可导入的源码扩展清单; Docker/产物部署返回空数组 (无前端源码树)
|
||||
const sourceNs = useQuery({ queryKey: QK.extPagesSourceNamespaces, queryFn: api.extPagesSourceNamespaces })
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: QK.extPages })
|
||||
const enabledMut = useMutation({
|
||||
@@ -44,6 +49,22 @@ export function SettingsPagePacksPanel() {
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
/** 试载刚入库的产物并回填 manifest; 校验失败抛错 (调用方负责 toast)。 */
|
||||
async function trialLoadAndRegister(id: string): Promise<void> {
|
||||
let label = '', path = '', error = ''
|
||||
try {
|
||||
const m = await import(/* @vite-ignore */ `${runtimeBundleUrl(id)}?trial=${Date.now()}`)
|
||||
const ext = packToExtension(m.default)
|
||||
label = ext.navigation?.[0]?.label ?? id
|
||||
path = ext.routes?.[0]?.path ?? ''
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
await api.extPagesPutManifest(id, { label, path, error })
|
||||
await invalidate()
|
||||
if (error) throw new Error(error)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
const id = idInput.trim()
|
||||
@@ -53,24 +74,47 @@ export function SettingsPagePacksPanel() {
|
||||
try {
|
||||
await api.extPagesUpload(id, file)
|
||||
// 试载: 直接 import 刚上传的产物, 契约校验失败则停用并回填错误
|
||||
let label = '', path = '', error = ''
|
||||
try {
|
||||
const m = await import(/* @vite-ignore */ `${runtimeBundleUrl(id)}?trial=${Date.now()}`)
|
||||
const ext = packToExtension(m.default)
|
||||
label = ext.navigation?.[0]?.label ?? id
|
||||
path = ext.routes?.[0]?.path ?? ''
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
await api.extPagesPutManifest(id, { label, path, error })
|
||||
await invalidate()
|
||||
if (error) toast(`页面插件校验失败: ${error}`, 'error')
|
||||
else { toast('页面插件已启用, 刷新页面后生效', 'success'); setIdInput(''); if (fileRef.current) fileRef.current.value = '' }
|
||||
await trialLoadAndRegister(id)
|
||||
toast('页面插件已启用, 刷新页面后生效', 'success')
|
||||
setIdInput('')
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
} catch (e) {
|
||||
toast(`页面插件校验失败: ${e instanceof Error ? e.message : e}`, 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFile() {
|
||||
const file = importFileRef.current?.files?.[0]
|
||||
if (!file) { toast('请选择源码文件 (.tsx)', 'error'); return }
|
||||
setBusyImport(true)
|
||||
try {
|
||||
const meta = await api.extPagesImportSource(file)
|
||||
await trialLoadAndRegister(meta.id)
|
||||
toast(`已导入并启用 ${meta.id}, 刷新页面后生效`, 'success')
|
||||
if (importFileRef.current) importFileRef.current.value = ''
|
||||
} catch (e) {
|
||||
toast(`源码导入失败: ${e instanceof Error ? e.message : e}`, 'error')
|
||||
} finally {
|
||||
setBusyImport(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportFromProject() {
|
||||
if (!importNs) { toast('请选择要导入的源码扩展', 'error'); return }
|
||||
setBusyImport(true)
|
||||
try {
|
||||
const meta = await api.extPagesImportFromProject(importNs)
|
||||
await trialLoadAndRegister(meta.id)
|
||||
toast(`已导入并启用 ${meta.id}, 刷新页面后生效`, 'success')
|
||||
} catch (e) {
|
||||
toast(`源码导入失败: ${e instanceof Error ? e.message : e}`, 'error')
|
||||
} finally {
|
||||
setBusyImport(false)
|
||||
}
|
||||
}
|
||||
|
||||
const packs = list.data ?? []
|
||||
const bootErrors = getFrontendExtensionLoadErrors().filter(e => e.source.startsWith('runtime:'))
|
||||
|
||||
@@ -118,6 +162,58 @@ export function SettingsPagePacksPanel() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80">导入源码</div>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-tight text-foreground">.tsx 源码一键导入, 自动编译挂载</h3>
|
||||
<p className="mt-2 max-w-3xl text-xs leading-5 text-secondary">
|
||||
上传源码扩展文件 (.tsx) 或从本项目 <span className="font-mono text-accent">frontend/src/custom/</span> 直接选择,
|
||||
后端用项目自带的 esbuild 自动编译并适配契约, 免重新构建。产物为标准页面插件, 与上方上传的包同一管理。
|
||||
边界: 源码里注册的插槽(slot)会被跳过; 页面内路由参数读到空值; 仅源码/桌面部署可发起导入。
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input ref={importFileRef} type="file" accept=".tsx" className="hidden" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-btn border border-border bg-elevated px-3 text-xs text-secondary hover:bg-elevated/80 hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileCode className="h-3.5 w-3.5" />
|
||||
{importFileRef.current?.files?.[0]?.name ?? '选择 .tsx 源文件'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyImport}
|
||||
onClick={handleImportFile}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{busyImport ? '编译导入中…' : '编译并导入'}
|
||||
</button>
|
||||
{(sourceNs.data?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<span className="ml-2 text-[11px] text-muted">或从本项目导入:</span>
|
||||
<select
|
||||
value={importNs}
|
||||
onChange={e => setImportNs(e.target.value)}
|
||||
className="h-9 rounded-btn border border-border bg-base px-2 text-xs text-foreground"
|
||||
>
|
||||
<option value="">选择扩展…</option>
|
||||
{sourceNs.data!.map(ns => <option key={ns} value={ns}>{ns}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyImport || !importNs}
|
||||
onClick={handleImportFromProject}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-btn border border-border bg-elevated px-3 text-xs text-secondary hover:bg-elevated/80 hover:text-foreground disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<FolderDown className="h-3.5 w-3.5" />
|
||||
导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{bootErrors.length > 0 && (
|
||||
<section className="rounded-card border border-danger/30 bg-danger/5 p-4 text-xs leading-5 text-secondary">
|
||||
<div className="mb-1 flex items-center gap-1.5 font-medium text-danger">
|
||||
|
||||
@@ -130,7 +130,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'overview', element: <Navigate to="/" replace /> },
|
||||
{ path: 'analysis', element: <Navigate to="/settings?tab=ext-pages" replace /> },
|
||||
{ path: 'analysis', element: <Navigate to="/settings?tab=pages§ion=ext-pages" replace /> },
|
||||
{ path: 'analysis/:menuId', element: <AnalysisDetail /> },
|
||||
{ path: 'watchlist', element: <Watchlist /> },
|
||||
{ path: 'screener', element: <Screener /> },
|
||||
|
||||
Reference in New Issue
Block a user