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:
shy3130
2026-10-22 17:47:59 +08:00
parent b3dc03707d
commit 01257c83ee
19 changed files with 1131 additions and 87 deletions
+31 -5
View File
@@ -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)
+82 -1
View File
@@ -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): 热更新产物后浏览器立即取新, 未变时可用缓存协商。"""
+88
View File
@@ -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")
+2
View File
@@ -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)
+202
View File
@@ -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
+84
View File
@@ -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
View File
@@ -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)。
### 数据源插件化
+64
View File
@@ -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
View File
@@ -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[]
+1
View File
@@ -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) =>
+10
View File
@@ -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} />
}
+28 -14
View File
@@ -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 }
+86 -38
View File
@@ -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" />
+69 -12
View File
@@ -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&section=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>
)
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 页面管理 — 原「菜单设置 / 扩展页面 / 页面插件」三个设置 tab 的三合一外壳。
*
* ?section=<menus|ext-pages|page-packs> 深链定位子区块; Settings.tsx 会把旧
* ?tab=menus/ext-pages/page-packs 链接重写为 ?tab=pages&section=<旧值>, 历史
* 链接与 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>
)
}
+110 -14
View File
@@ -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">
+1 -1
View File
@@ -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&section=ext-pages" replace /> },
{ path: 'analysis/:menuId', element: <AnalysisDetail /> },
{ path: 'watchlist', element: <Watchlist /> },
{ path: 'screener', element: <Screener /> },