"""HTML 模板管理 API(模板管理页)。 后端存储 :mod:`deerflow.runtime.scheduler.template_store`(文件型, ``{base_dir}/html-templates//``),启动时把随包内置模板 seed 进去(缺失才复制)。 读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置) 仅管理员——与敏感词管理同一鉴权口径。旧端点 ``GET /api/scheduled-tasks/html-templates`` 保持不变(内部同样读存储),本路由是它的管理超集。 结构化模板(kind="structured",如 no.1)额外暴露默认数据的读写接口: ``GET /{id}/data`` 读、``PUT /{id}/data`` 整体替换(管理页「编辑数据」的持久化入口, 替换前经转换契约校验)。``GET /{id}/content`` 默认返回组合后的完整文档(预览用), ``?raw=1`` 返回骨架原文(编辑用)。 """ from __future__ import annotations import asyncio import re from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog from app.gateway.deps import get_optional_user_from_request from deerflow.runtime.scheduler import template_fill, template_store router = APIRouter(prefix="/api/html-templates", tags=["html-templates"]) async def _require_admin(request: Request) -> None: """写操作仅管理员;关闭鉴权时(request 无用户)放行,与 scheduled_tasks 口径一致。""" user = await get_optional_user_from_request(request) if user is None: return if getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="仅管理员可管理 HTML 模板") def _not_found(template_id: str) -> HTTPException: return HTTPException(status_code=404, detail=f"模板不存在:{template_id}") class TemplateCreateRequest(BaseModel): template_id: str = Field(..., description="模板 id(字母/数字/./-/_,创建后不可改)") name: str = Field(..., description="显示名称,如 no.28") description: str = "" html: str = Field(..., description="完整 HTML 模板正文") class TemplateUpdateRequest(BaseModel): name: str | None = None description: str | None = None html: str | None = Field(default=None, description="新的模板正文(上传覆盖走这里)") class TemplateDataUpdateRequest(BaseModel): data: dict[str, Any] = Field(..., description="新的默认数据(整体替换 data.json,须通过契约校验)") manifest: dict[str, Any] | None = Field( default=None, description="可选:新的 MANIFEST(整体替换 manifest.json,系统名称/导航页面/交互规则)", ) class FieldVisibilityUpdateRequest(BaseModel): hidden: list[str] = Field(default_factory=list, description="要隐藏的字段显示名列表(其余字段全部显示)") extract_locked: list[str] | None = Field( default=None, description="可选:不接受抽取更新的字段名单(None=不动;给出则整体替换,空表=全部接受)", ) class CustomModuleListUpdateRequest(BaseModel): modules: list[dict[str, Any]] = Field(..., description="自定义模块全量列表(整体替换)") class CustomModuleGenerateRequest(BaseModel): page: str = Field(..., description="挂载的页面编号(manifest.页面 的 编号)") title: str = Field(default="", description="模块标题(留空由模型起名)") description: str = Field(..., min_length=4, description="用自然语言描述想新增的展示内容") model_name: str | None = Field(default=None, description="可选:指定生成模型") @router.get("") async def list_html_templates(request: Request) -> dict: """模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。""" async def load() -> dict: return {"templates": await asyncio.to_thread(template_store.list_templates)} return await cached_catalog(request, "html_templates", "admin-list", load) @router.get("/{template_id}") async def get_html_template(template_id: str, request: Request) -> dict[str, Any]: async def load() -> dict[str, Any]: meta = await asyncio.to_thread(template_store.get_template_meta, template_id) if meta is None: raise _not_found(template_id) return meta return await cached_catalog(request, "html_templates", catalog_key("meta", template_id), load) @router.get("/{template_id}/content") async def get_html_template_content( template_id: str, request: Request, raw: bool = False ) -> dict: """模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物); ``?raw=1`` 返回正文原文(结构化模板 = 带 __PAGE_* 标记的骨架,编辑用)。""" async def load() -> dict: if raw: try: html = await asyncio.to_thread(template_store.read_template_html, template_id) except template_store.TemplateStoreError: raise _not_found(template_id) else: try: html = await asyncio.to_thread(template_fill.read_template_text, template_id) except template_fill.TemplateError: raise _not_found(template_id) return {"template_id": template_id, "html": html} suffix = "raw" if raw else "doc" return await cached_catalog(request, "html_templates", catalog_key("content", template_id, suffix), load) @router.get("/{template_id}/data") async def get_html_template_data(template_id: str, request: Request) -> dict[str, Any]: """结构化模板的默认数据(data.json) + MANIFEST + 各模块中文名(管理页「编辑数据」用)。""" async def load() -> dict[str, Any]: try: data, manifest, modules = await asyncio.gather( asyncio.to_thread(template_store.read_template_data, template_id), asyncio.to_thread(template_store.read_template_manifest, template_id), asyncio.to_thread(template_fill.structured_module_labels, template_id), ) except (template_store.TemplateStoreError, template_fill.TemplateError) as exc: status = 404 if "不存在" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) return { "template_id": template_id, "data": data, "manifest": manifest, "modules": modules, } return await cached_catalog(request, "html_templates", catalog_key("data", template_id), load) @router.put("/{template_id}/data") async def update_html_template_data( template_id: str, body: TemplateDataUpdateRequest, request: Request ) -> dict[str, Any]: """整体替换结构化模板的默认数据/MANIFEST 并持久化(仅管理员;先经契约校验)。""" await _require_admin(request) try: meta = template_store.write_template_data( template_id, body.data, manifest=body.manifest ) except template_store.TemplateStoreError as exc: status = 404 if "不存在" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) await invalidate_catalog(request, "html_templates") return meta # ── 字段显示 / 自定义模块 ──────────────────────────────────────────────────── @router.get("/{template_id}/fields") async def get_html_template_fields(template_id: str, request: Request) -> dict[str, Any]: """字段目录(fields.json,含中文说明)× 当前可见性 + 页面清单 + 自定义模块列表。""" async def load() -> dict[str, Any]: try: catalog, manifest = await asyncio.gather( asyncio.to_thread(template_store.read_template_fields, template_id), asyncio.to_thread(template_store.read_template_manifest, template_id), ) except template_store.TemplateStoreError as exc: status = 404 if "不存在" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) hidden = manifest.get("字段显示") hidden = hidden if isinstance(hidden, dict) else {} locks = manifest.get("字段抽取关闭") locks = locks if isinstance(locks, dict) else {} fields = [ { "label": str(f.get("label") or ""), "pages": [str(p) for p in (f.get("pages") or [])], "description": str(f.get("description") or ""), "visible": hidden.get(str(f.get("label"))) is not False, # 是否接受定时任务抽取数据更新(默认 True);布局分区无独立数据键,恒为 True 且前端禁用开关。 "extract": locks.get(str(f.get("label"))) is not True, "has_key": bool(f.get("keys")), } for f in catalog ] pages = [ {"key": str(p.get("编号") or ""), "label": str(p.get("名称") or p.get("编号") or "")} for p in manifest.get("页面") or [] if isinstance(p, dict) ] custom = manifest.get("自定义模块") return { "template_id": template_id, "fields": fields, "pages": pages, "custom_modules": custom if isinstance(custom, list) else [], } return await cached_catalog(request, "html_templates", catalog_key("fields", template_id), load) @router.put("/{template_id}/fields") async def update_html_template_fields( template_id: str, body: FieldVisibilityUpdateRequest, request: Request ) -> dict[str, Any]: """整体替换字段显示/隐藏配置(可选一并替换抽取锁定名单;仅管理员)。""" await _require_admin(request) try: meta = template_store.write_template_field_visibility(template_id, body.hidden) if body.extract_locked is not None: template_store.write_template_field_locks(template_id, body.extract_locked) except template_store.TemplateStoreError as exc: status = 404 if "不存在" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) await invalidate_catalog(request, "html_templates") return meta @router.put("/{template_id}/custom-modules") async def update_html_template_custom_modules( template_id: str, body: CustomModuleListUpdateRequest, request: Request ) -> dict[str, Any]: """自定义模块列表整体替换(入库前逐条消毒;仅管理员)。""" await _require_admin(request) try: meta = template_store.write_template_custom_modules(template_id, body.modules) except template_store.TemplateStoreError as exc: status = 404 if "不存在" in str(exc) else 400 raise HTTPException(status_code=status, detail=str(exc)) await invalidate_catalog(request, "html_templates") return meta _CUSTOM_MODULE_PROMPT = """你是资深前端设计师,为知识数据展示系统(红白主题)绘制一个自定义展示模块的 HTML 片段。 主题与可直接使用的样式(不要自造 CSS,尽量复用): - 颜色: 背景 #f5f5f6、卡片白 #fff、主红 #c8102e、深红 #8f1024、浅红底 #fff1f3、正文 #262626、次要 #737373 - 布局类: panel(白卡容器) panel-title(红色竖条标题) grid2(两列) metrics(三列指标组) - 组件类: metric metric-label metric-value metric-unit metric-note(指标卡) | chip chip-red chip-green chip-orange(标签) - 组件类: text-list text-item(条目列表) | info-grid info-item(信息格) | bar-row bar(横条统计) | entry-grid entry-card(入口卡) - 也可以用少量 inline style 微调(颜色只允许上述色板) 硬性约束: 1. 只输出一个最外层
包裹的 HTML 片段,不要 ///