Compare commits

..
2 Commits
19 changed files with 304 additions and 4783 deletions

No files matched your search

-2
View File
@@ -93,8 +93,6 @@ hermes-agent-main/
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_large_file_safety.py !offline-backend-20260512/backend/tests/test_large_file_safety.py
!offline-backend-20260512/backend/tests/test_account_authentication.py !offline-backend-20260512/backend/tests/test_account_authentication.py
# HTML 结构化模板(no.1)引擎/存储/路由回归
!offline-backend-20260512/backend/tests/test_structured_templates.py
!offline-backend-20260512/backend/tests/test_html_runtime_addresses.py !offline-backend-20260512/backend/tests/test_html_runtime_addresses.py
!offline-backend-20260512/backend/tests/test_app_identity.py !offline-backend-20260512/backend/tests/test_app_identity.py
!offline-backend-20260512/backend/tests/test_roundtable_room_limit.py !offline-backend-20260512/backend/tests/test_roundtable_room_limit.py
@@ -24,9 +24,6 @@ export type HtmlPageConfig = {
model_name?: string | null; model_name?: string | null;
/** 仅当参考选了内置模板(tpl:<id>)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */ /** 仅当参考选了内置模板(tpl:<id>)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */
template_extract?: boolean; template_extract?: boolean;
/** 仅当参考选了内置模板(tpl:<id>)且开启抽取时生效:抽取结果写回模板 data.json
* (默认数据持久化,页面/后续任务沿用);系统名称等 MANIFEST 不受影响。 */
template_data_writeback?: boolean;
}; };
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。 /** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
+236 -266
View File
@@ -4,23 +4,19 @@
* HTML 模板管理(仅 admin):管理定时任务「HTML 模板」库—— * HTML 模板管理(仅 admin):管理定时任务「HTML 模板」库——
* 列表 / 预览 / 在线编辑 / 上传覆盖 / 新增 / 删除 / 重置内置。 * 列表 / 预览 / 在线编辑 / 上传覆盖 / 新增 / 删除 / 重置内置。
* *
* 视觉与任务分析定时(task-analysis.css)同一套浅红规范:TDesign 控件 + lucide * 后端 `/api/html-templates`(存储 `{base_dir}/html-templates/<id>/`,内置 27 个
* 图标 + ht- 前缀样式。结构化模板(kind=structured,如 no.1)多一个「编辑数据」 * 模板启动 seed、缺失才复制不覆盖修改)。改完即时生效:调度器每次执行直接读
* 页签:表单化编辑默认数据并持久化到后台 data.json(详见 * 存储文件,无需重启。内置模板可编辑/上传覆盖/重置回随包原版但不可删除;自建
* html-template-data-editor.tsx);预览页右下角的齿轮按钮是模板 runtime 自带的 * 模板可删。上传的模板若带单行 `const DATA={…};` 注入行会自动成为「可转换」
* 页面级 JSON 编辑器,仅改浏览器本地。 * 模板(定时任务的「JSON 转换填充」管线零代码接入)。
*/ */
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { import {
Braces,
Code2,
Download, Download,
Eye,
FileCode2, FileCode2,
Layers, Layers,
LayoutGrid,
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -31,21 +27,27 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button, Dialog, Input, Popconfirm, Tabs, Tag, Textarea } from "tdesign-react"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { import {
formatTemplateSize, formatTemplateSize,
useHtmlTemplateActions, useHtmlTemplateActions,
useHtmlTemplateContent, useHtmlTemplateContent,
useHtmlTemplateRawContent,
useHtmlTemplatesAdmin, useHtmlTemplatesAdmin,
type HtmlTemplateMeta, type HtmlTemplateMeta,
} from "@/strategy-components/api/html-templates"; } from "@/strategy-components/api/html-templates";
import HtmlTemplateDataEditor from "./html-template-data-editor";
import HtmlTemplatePageConfig from "./html-template-page-config";
import "./html-templates.css";
function formatTime(value?: string | null): string { function formatTime(value?: string | null): string {
return value ? new Date(value).toLocaleString() : "—"; return value ? new Date(value).toLocaleString() : "—";
@@ -53,18 +55,14 @@ function formatTime(value?: string | null): string {
/** 从文件名推一个合法模板 id(no.28.html → no28;不合法字符折成 -)。 */ /** 从文件名推一个合法模板 id(no.28.html → no28;不合法字符折成 -)。 */
function slugifyId(raw: string): string { function slugifyId(raw: string): string {
return ( return raw
raw
.replace(/\.(html?|htm)$/i, "") .replace(/\.(html?|htm)$/i, "")
.trim() .trim()
.replace(/[^A-Za-z0-9._-]+/g, "-") .replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^[._-]+/, "") .replace(/^[._-]+/, "")
.slice(0, 64) || "" .slice(0, 64) || "";
);
} }
type DetailTab = "preview" | "data" | "config" | "html";
export default function HtmlTemplatesPage() { export default function HtmlTemplatesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin"; const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: templates = [], isLoading, refetch } = useHtmlTemplatesAdmin(); const { data: templates = [], isLoading, refetch } = useHtmlTemplatesAdmin();
@@ -72,37 +70,29 @@ export default function HtmlTemplatesPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [tab, setTab] = useState<DetailTab>("preview"); const [tab, setTab] = useState<"preview" | "edit">("preview");
// 选中项的内容(服务端权威)与本地编辑草稿;selectedId/服务端内容变化时重置。
const { data: serverHtml, isLoading: contentLoading } = useHtmlTemplateContent(selectedId);
const [htmlDraft, setHtmlDraft] = useState<string | null>(null);
const [metaDraft, setMetaDraft] = useState<{ name: string; description: string } | null>(null);
useEffect(() => {
setHtmlDraft(serverHtml ?? null);
}, [serverHtml, selectedId]);
const selected = useMemo( const selected = useMemo(
() => templates.find((t) => t.id === selectedId) ?? null, () => templates.find((t) => t.id === selectedId) ?? null,
[templates, selectedId], [templates, selectedId],
); );
const structured = selected?.kind === "structured";
// 预览用组合后的完整文档;编辑用正文原文(结构化模板 = 带 __PAGE_* 标记的骨架)。
const { data: serverHtml, isLoading: contentLoading } = useHtmlTemplateContent(selectedId);
const { data: rawHtml } = useHtmlTemplateRawContent(selectedId, structured);
const editSource = structured ? (rawHtml ?? null) : (serverHtml ?? null);
const [htmlDraft, setHtmlDraft] = useState<string | null>(null);
const [metaDraft, setMetaDraft] = useState<{ name: string; description: string } | null>(null);
useEffect(() => {
setHtmlDraft(editSource);
}, [editSource, selectedId]);
useEffect(() => { useEffect(() => {
setMetaDraft(selected ? { name: selected.name, description: selected.description } : null); setMetaDraft(selected ? { name: selected.name, description: selected.description } : null);
}, [selected]); }, [selected]);
useEffect(() => { useEffect(() => {
if (!selectedId && templates.length > 0) setSelectedId(templates[0].id); if (!selectedId && templates.length > 0) setSelectedId(templates[0].id);
if (selectedId && !templates.some((t) => t.id === selectedId)) { if (selectedId && !templates.some((t) => t.id === selectedId)) {
setSelectedId(templates[0]?.id ?? null); setSelectedId(templates[0]?.id ?? null);
} }
}, [selectedId, templates]); }, [selectedId, templates]);
// 切到非结构化模板时收起「编辑数据」页签。
useEffect(() => {
if ((tab === "data" || tab === "config") && !structured) setTab("preview");
}, [tab, structured]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -112,7 +102,7 @@ export default function HtmlTemplatesPage() {
); );
}, [templates, search]); }, [templates, search]);
const htmlDirty = htmlDraft !== null && htmlDraft !== (editSource ?? ""); const htmlDirty = htmlDraft !== null && htmlDraft !== (serverHtml ?? "");
const metaDirty = const metaDirty =
!!selected && !!selected &&
!!metaDraft && !!metaDraft &&
@@ -168,9 +158,10 @@ export default function HtmlTemplatesPage() {
const handleReset = async () => { const handleReset = async () => {
if (!selected) return; if (!selected) return;
if (!window.confirm(`确认把内置模板「${selected.name}」重置回随包原版?当前修改将丢失。`)) return;
try { try {
await actions.reset.mutateAsync(selected.id); await actions.reset.mutateAsync(selected.id);
toast.success(`已把「${selected.name}」重置回随包原版`); toast.success("已重置回随包原版");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "重置失败"); toast.error(error instanceof Error ? error.message : "重置失败");
} }
@@ -178,6 +169,12 @@ export default function HtmlTemplatesPage() {
const handleDelete = async () => { const handleDelete = async () => {
if (!selected) return; if (!selected) return;
if (
!window.confirm(
`确认删除模板「${selected.name}」?引用它的定时任务将无法执行,该操作不可撤销。`,
)
)
return;
try { try {
await actions.remove.mutateAsync(selected.id); await actions.remove.mutateAsync(selected.id);
toast.success("模板已删除"); toast.success("模板已删除");
@@ -235,129 +232,108 @@ export default function HtmlTemplatesPage() {
} }
return ( return (
<section className="ht-page"> <div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
<header className="ht-page-head"> {/* 顶栏 */}
<div className="ht-page-head-copy"> <div className="flex items-center justify-between gap-3 border-b border-border/70 px-5 py-4">
<h1>HTML 模板管理</h1> <div className="flex items-center gap-2">
<p> <Layers className="h-5 w-5 text-[var(--color-main,#0062D9)]" />
定时任务的 HTML 模板库(共 {templates.length} 个)。编辑即时生效、无需重启;内置模板可重置回原版, <div>
自建模板可删除;结构化模板支持「编辑数据」并持久化到后台。 <div className="text-base font-semibold text-foreground">HTML 模板管理</div>
</p> <div className="text-xs text-muted-foreground">
定时任务的 HTML 模板库(共 {templates.length} 个)。编辑/上传覆盖即时生效,无需重启;内置模板可重置回原版,自建模板可删除。
</div> </div>
<div className="ht-page-head-actions"> </div>
<Button theme="primary" onClick={() => setCreateOpen(true)}> </div>
<Plus size={15} /> 新建模板 <Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> 新建模板
</Button> </Button>
</div> </div>
</header>
<div className="ht-body"> <div className="flex min-h-0 flex-1">
{/* 左:模板列表 */} {/* 左:模板列表 */}
<aside className="ht-list"> <div className="flex w-72 min-w-60 flex-col border-r border-border/70">
<div className="ht-list-search"> <div className="relative p-3">
<Search className="pointer-events-none absolute top-1/2 left-6 size-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
clearable
placeholder="搜索 id / 名称 / 描述"
prefixIcon={<Search size={15} />}
value={search} value={search}
onChange={(value) => setSearch(String(value ?? ""))} onChange={(e) => setSearch(e.target.value)}
placeholder="搜索 id / 名称"
className="pl-8"
/> />
</div> </div>
<div className="ht-list-items"> <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{isLoading && <div className="ht-list-empty">正在加载模板列表…</div>} {isLoading && <div className="px-2 py-4 text-xs text-muted-foreground">加载中…</div>}
{!isLoading && filtered.length === 0 && ( {!isLoading && filtered.length === 0 && (
<div className="ht-list-empty">没有匹配的模板</div> <div className="px-2 py-4 text-xs text-muted-foreground">没有匹配的模板</div>
)} )}
{filtered.map((t) => ( {filtered.map((t) => (
<button <TemplateListItem
key={t.id} key={t.id}
type="button" template={t}
className={`ht-tpl-item${t.id === selectedId ? " is-active" : ""}`} active={t.id === selectedId}
onClick={() => { onClick={() => {
setSelectedId(t.id); setSelectedId(t.id);
setTab("preview"); setTab("preview");
}} }}
> />
<span className="ht-tpl-item-top">
<FileCode2 size={15} />
<span className="ht-tpl-item-name">{t.name}</span>
</span>
<span className="ht-tpl-item-meta">
<span>{t.id}</span>
<span>{formatTemplateSize(t.size)}</span>
<span>{t.builtin ? "内置" : "自建"}</span>
{t.convertible && <span className="ht-dot" title="可转换填充" />}
{t.kind === "structured" && (
<span className="ht-dot is-struct" title="结构化模板(可编辑数据)" />
)}
</span>
</button>
))} ))}
</div> </div>
</aside> </div>
{/* 右:详情 / 编辑 */} {/* 右:详情 / 编辑 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{!selected ? ( {!selected ? (
<div className="ht-detail"> <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
<div className="ht-preview-state"> {isLoading ? "加载模板列表…" : "选择左侧模板查看详情"}
{isLoading ? "正在加载模板列表…" : "选择左侧模板查看详情"}
</div>
</div> </div>
) : ( ) : (
<div className="ht-detail"> <>
<div className="ht-detail-head"> {/* 详情头:名称/描述编辑 + 徽标 + 操作 */}
<div className="ht-detail-tags"> <div className="flex flex-col gap-3 border-b border-border/70 px-5 py-4">
<Tag size="small" theme={selected.builtin ? "default" : "warning"} variant="light"> <div className="flex items-center gap-2">
<Badge variant={selected.builtin ? "secondary" : "outline"}>
{selected.builtin ? "内置" : "自建"} {selected.builtin ? "内置" : "自建"}
</Tag> </Badge>
{structured && ( <Badge variant={selected.convertible ? "default" : "outline"}>
<Tag size="small" theme="primary" variant="light">
结构化 · 可编辑数据
</Tag>
)}
<Tag
size="small"
theme={selected.convertible ? "success" : "default"}
variant="light"
>
{selected.convertible ? "可转换填充" : "只展示"} {selected.convertible ? "可转换填充" : "只展示"}
</Tag> </Badge>
<span className="ht-detail-meta"> <span className="text-xs text-muted-foreground">
id:{selected.id} · {formatTemplateSize(selected.size)} · 更新于{" "} id:{selected.id} · {formatTemplateSize(selected.size)} · 更新于{" "}
{formatTime(selected.updatedAt)} {formatTime(selected.updatedAt)}
</span> </span>
</div> </div>
<div className="ht-detail-form"> <div className="flex flex-wrap items-end gap-2">
<label className="ht-detail-field"> <div className="flex min-w-40 flex-col gap-1">
<span>名称</span> <span className="text-xs text-muted-foreground">名称</span>
<Input <Input
style={{ width: 200 }}
value={metaDraft?.name ?? ""} value={metaDraft?.name ?? ""}
onChange={(value) => onChange={(e) =>
setMetaDraft((v) => (v ? { ...v, name: String(value ?? "") } : v)) setMetaDraft((v) => (v ? { ...v, name: e.target.value } : v))
} }
className="h-8 w-52"
/> />
</label> </div>
<label className="ht-detail-field" style={{ flex: 1 }}> <div className="flex min-w-40 flex-1 flex-col gap-1">
<span>描述(定时任务下拉中展示)</span> <span className="text-xs text-muted-foreground">描述(定时任务下拉中展示)</span>
<Input <Input
value={metaDraft?.description ?? ""} value={metaDraft?.description ?? ""}
placeholder="可选" onChange={(e) =>
onChange={(value) => setMetaDraft((v) => (v ? { ...v, description: e.target.value } : v))
setMetaDraft((v) => (v ? { ...v, description: String(value ?? "") } : v))
} }
className="h-8"
placeholder="可选"
/> />
</label> </div>
<Button <Button
size="small" size="sm"
variant="outline" variant="outline"
onClick={handleSaveMeta}
disabled={!metaDirty || actions.update.isPending} disabled={!metaDirty || actions.update.isPending}
onClick={() => void handleSaveMeta()}
> >
<Pencil size={14} /> 保存信息 <Pencil className="h-4 w-4" /> 保存信息
</Button> </Button>
</div> </div>
<div className="ht-detail-ops"> <div className="flex flex-wrap items-center gap-2">
<input <input
ref={overwriteInputRef} ref={overwriteInputRef}
type="file" type="file"
@@ -369,169 +345,111 @@ export default function HtmlTemplatesPage() {
}} }}
/> />
<Button <Button
size="small" size="sm"
variant="outline" variant="outline"
onClick={() => overwriteInputRef.current?.click()} onClick={() => overwriteInputRef.current?.click()}
disabled={actions.update.isPending} disabled={actions.update.isPending}
> >
<Upload size={14} /> 上传 HTML 覆盖 <Upload className="h-4 w-4" /> 上传 HTML 覆盖
</Button> </Button>
<Button size="small" variant="outline" onClick={handleDownload}> <Button size="sm" variant="outline" onClick={handleDownload}>
<Download size={14} /> 下载 <Download className="h-4 w-4" /> 下载
</Button> </Button>
{selected.builtin ? ( {selected.builtin && (
<Popconfirm
content={`重置后「${selected.name}」的本地修改将丢失,确定?`}
theme="danger"
onConfirm={() => void handleReset()}
>
<Button size="small" variant="outline" disabled={actions.reset.isPending}>
<RotateCcw size={14} /> 重置原版
</Button>
</Popconfirm>
) : (
<Popconfirm
content="删除后引用它的定时任务将无法执行,确定?"
theme="danger"
placement="bottom-right"
onConfirm={() => void handleDelete()}
>
<Button size="small" variant="outline" theme="danger">
<Trash2 size={14} /> 删除
</Button>
</Popconfirm>
)}
<div className="ht-ops-gap" />
<Button <Button
size="small" size="sm"
variant="text" variant="outline"
onClick={handleReset}
disabled={actions.reset.isPending}
>
<RotateCcw className="h-4 w-4" /> 重置原版
</Button>
)}
{!selected.builtin && (
<Button
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={handleDelete}
disabled={actions.remove.isPending}
>
<Trash2 className="h-4 w-4" /> 删除
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => refetch()} onClick={() => refetch()}
title="刷新列表" title="刷新列表"
> >
<RefreshCw size={14} className={isLoading ? "ht-spin" : undefined} /> <RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
</Button> </Button>
</div> </div>
</div> </div>
<div className="ht-detail-body"> {/* 预览 / 编辑 */}
<Tabs <Tabs
className="ht-detail-tabs"
value={tab} value={tab}
onChange={(value) => setTab(value as DetailTab)} onValueChange={(v) => setTab(v as "preview" | "edit")}
className="flex min-h-0 flex-1 flex-col gap-0"
> >
<Tabs.TabPanel <div className="border-b border-border/70 px-5 py-2">
value="preview" <TabsList>
label={ <TabsTrigger value="preview">预览</TabsTrigger>
<span className="ht-tab-label"> <TabsTrigger value="edit">编辑 HTML</TabsTrigger>
<Eye size={14} /> 预览 </TabsList>
</span> </div>
} <TabsContent value="preview" className="min-h-0 flex-1 p-0">
>
<div className="ht-preview-pane">
{contentLoading ? ( {contentLoading ? (
<div className="ht-preview-state">正在加载模板内容…</div> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
加载模板内容…
</div>
) : ( ) : (
<iframe <iframe
title={`模板预览 ${selected.name}`} title={`模板预览 ${selected.name}`}
srcDoc={serverHtml ?? ""} srcDoc={serverHtml ?? ""}
sandbox="allow-scripts allow-forms allow-popups allow-modals" sandbox="allow-scripts allow-forms allow-popups allow-modals"
className="h-full w-full border-0 bg-white"
/> />
)} )}
</div> </TabsContent>
</Tabs.TabPanel> <TabsContent value="edit" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
{structured && (
<Tabs.TabPanel
value="data"
label={
<span className="ht-tab-label">
<Braces size={14} /> 编辑数据
</span>
}
>
<HtmlTemplateDataEditor key={selected.id} templateId={selected.id} />
</Tabs.TabPanel>
)}
{structured && (
<Tabs.TabPanel
value="config"
label={
<span className="ht-tab-label">
<LayoutGrid size={14} /> 字段与模块
</span>
}
>
<HtmlTemplatePageConfig key={selected.id} templateId={selected.id} />
</Tabs.TabPanel>
)}
<Tabs.TabPanel
value="html"
label={
<span className="ht-tab-label">
<Code2 size={14} /> 编辑 HTML
</span>
}
>
<div className="ht-html-pane">
<Textarea <Textarea
value={htmlDraft ?? ""} value={htmlDraft ?? ""}
onChange={(value) => setHtmlDraft(String(value ?? ""))} onChange={(e) => setHtmlDraft(e.target.value)}
spellCheck={false} spellCheck={false}
placeholder={structured ? "结构化模板骨架(须保留注入标记)…" : "模板 HTML 正文…"} placeholder="模板 HTML 正文…"
className="min-h-0 flex-1 resize-none rounded-md border font-mono text-xs leading-relaxed"
/> />
<div className="ht-pane-toolbar"> <div className="flex items-center justify-between">
<span className="ht-pane-hint"> <span className="text-xs text-muted-foreground">
{structured ? ( 保存后即时生效;带单行
<> <code className="mx-1 rounded bg-muted px-1">const DATA=&#123;…&#125;;</code>
这里编辑的是骨架,必须保留
<code>__PAGE_DATA__</code>
<code>__PAGE_MANIFEST__</code>
<code>__PAGE_RUNTIME__</code>
标记;改数据请用「编辑数据」页签
</>
) : (
<>
保存后即时生效;带单行 <code>const DATA=&#123;…&#125;;</code>{" "}
的模板自动成为「可转换填充」型 的模板自动成为「可转换填充」型
</>
)}
</span> </span>
<Button <Button
size="small" size="sm"
theme="primary" onClick={handleSaveHtml}
disabled={!htmlDirty || actions.update.isPending} disabled={!htmlDirty || actions.update.isPending}
onClick={() => void handleSaveHtml()}
> >
保存内容 {actions.update.isPending ? "保存中…" : "保存内容"}
</Button> </Button>
</div> </div>
</div> </TabsContent>
</Tabs.TabPanel>
</Tabs> </Tabs>
</div> </>
</div>
)} )}
</div> </div>
</div>
{/* 新建模板 */} {/* 新建模板 */}
<Dialog <Dialog open={createOpen} onOpenChange={setCreateOpen}>
className="ht-create-dialog" <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
visible={createOpen} <DialogHeader>
onClose={() => setCreateOpen(false)} <DialogTitle>新建 HTML 模板</DialogTitle>
header="新建 HTML 模板" </DialogHeader>
width={680} <div className="flex flex-col gap-3">
footer={ <div className="flex items-center gap-2">
<>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button theme="primary" loading={actions.create.isPending} onClick={() => void handleCreate()}>
创建
</Button>
</>
}
>
<div className="ht-create-grid">
<div className="ht-detail-ops" style={{ border: 0, padding: 0 }}>
<input <input
ref={createFileRef} ref={createFileRef}
type="file" type="file"
@@ -542,51 +460,103 @@ export default function HtmlTemplatesPage() {
e.target.value = ""; e.target.value = "";
}} }}
/> />
<Button size="small" variant="outline" onClick={() => createFileRef.current?.click()}> <Button
<Upload size={14} /> 上传 .html 文件 type="button"
size="sm"
variant="outline"
onClick={() => createFileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" /> 上传 .html 文件
</Button> </Button>
<span className="ht-pane-hint">或直接在下方粘贴 HTML;上传后 id/名称可用文件名预填</span> <span className="text-xs text-muted-foreground">
或直接在下方粘贴 HTML;上传后 id/名称可用文件名预填
</span>
</div> </div>
<div className="ht-create-row"> <div className="grid grid-cols-2 gap-3">
<label className="ht-create-field"> <div className="flex flex-col gap-1">
<span>模板 id(字母/数字/.-/_,创建后不可改)</span> <span className="text-xs text-muted-foreground">
模板 id(字母/数字/.-/_,创建后不可改)
</span>
<Input <Input
value={createForm.templateId} value={createForm.templateId}
onChange={(value) => setCreateForm((v) => ({ ...v, templateId: String(value ?? "") }))} onChange={(e) => setCreateForm((v) => ({ ...v, templateId: e.target.value }))}
placeholder="例如 no28 或 my-dashboard" placeholder="例如 no28 或 my-dashboard"
/> />
</label> </div>
<label className="ht-create-field"> <div className="flex flex-col gap-1">
<span>名称(下拉中展示)</span> <span className="text-xs text-muted-foreground">名称(下拉中展示)</span>
<Input <Input
value={createForm.name} value={createForm.name}
onChange={(value) => setCreateForm((v) => ({ ...v, name: String(value ?? "") }))} onChange={(e) => setCreateForm((v) => ({ ...v, name: e.target.value }))}
placeholder="例如 no.28" placeholder="例如 no.28"
/> />
</label>
</div> </div>
<label className="ht-create-field"> </div>
<span>描述(可选)</span> <div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">描述(可选)</span>
<Input <Input
value={createForm.description} value={createForm.description}
onChange={(value) => onChange={(e) => setCreateForm((v) => ({ ...v, description: e.target.value }))}
setCreateForm((v) => ({ ...v, description: String(value ?? "") }))
}
placeholder="模板用途说明" placeholder="模板用途说明"
/> />
</label> </div>
<label className="ht-create-field"> <div className="flex flex-col gap-1">
<span>模板 HTML 正文</span> <span className="text-xs text-muted-foreground">模板 HTML 正文</span>
<Textarea <Textarea
value={createForm.html} value={createForm.html}
onChange={(value) => setCreateForm((v) => ({ ...v, html: String(value ?? "") }))} onChange={(e) => setCreateForm((v) => ({ ...v, html: e.target.value }))}
spellCheck={false} spellCheck={false}
placeholder="<!doctype html>…" placeholder="<!doctype html>…"
autosize={{ minRows: 8, maxRows: 14 }} className="h-56 resize-y font-mono text-xs leading-relaxed"
/> />
</label>
</div> </div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
取消
</Button>
<Button onClick={handleCreate} disabled={actions.create.isPending}>
{actions.create.isPending ? "创建中…" : "创建"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
</section> </div>
);
}
function TemplateListItem({
template,
active,
onClick,
}: {
template: HtmlTemplateMeta;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"mb-1 flex w-full flex-col items-start gap-1 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:bg-accent/60",
active && "border-primary/30 bg-accent",
)}
>
<span className="flex w-full items-center gap-1.5">
<FileCode2 className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{template.name}</span>
{template.convertible && (
<span
className="size-1.5 shrink-0 rounded-full bg-emerald-500"
title="可转换填充(带 const DATA= 注入行)"
/>
)}
</span>
<span className="flex w-full items-center gap-1.5 pl-5 text-[11px] text-muted-foreground">
<span className="min-w-0 flex-1 truncate">{template.id}</span>
<span className="shrink-0">{template.builtin ? "内置" : "自建"}</span>
</span>
</button>
); );
} }
@@ -141,9 +141,6 @@ const emptyForm = {
// 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退 // 参考选了内置模板(tpl:<id>)时的「数据抽取」开关:默认开启(检索抽取+抽不到回退
// 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。 // 模板原数据),仅可转换模板生效;显式关闭=直接用模板默认数据。
html_template_extract: true, html_template_extract: true,
// 「抽取信息写回模板数据」开关:默认关闭。开启后执行时把抽取结果写回所选模板的
// data.json(默认数据持久化,页面/后续任务沿用);系统名称等 MANIFEST 不受影响。
html_template_writeback: false,
}; };
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */ /** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
@@ -337,8 +334,6 @@ export default function ScheduledTasksPage({
html_model_name: html.model_name ?? "", html_model_name: html.model_name ?? "",
// 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。 // 与后端缺省一致:未存该键(老任务)视为开启,仅显式 false 才关闭。
html_template_extract: html.template_extract !== false, html_template_extract: html.template_extract !== false,
// 老任务未存该键视为关闭。
html_template_writeback: html.template_data_writeback === true,
}); });
// Prefer the canonical cron_expr over the user-typed schedule_text: the // Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read, // latter may be natural language ("每天9点") that parseCron can't read,
@@ -415,10 +410,6 @@ export default function ScheduledTasksPage({
template_extract: form.html_reference_favorite_id.startsWith("tpl:") template_extract: form.html_reference_favorite_id.startsWith("tpl:")
? form.html_template_extract ? form.html_template_extract
: false, : false,
// 仅当参考是内置模板且有抽取时有意义:抽取结果写回模板 data.json(系统设置不动)。
template_data_writeback: form.html_reference_favorite_id.startsWith("tpl:")
? form.html_template_writeback
: false,
}, },
}; };
}; };
@@ -1433,24 +1424,6 @@ export default function ScheduledTasksPage({
/> />
</div> </div>
))} ))}
{/* 参考选了内置模板(tpl:<id>)且开启抽取时:抽取结果写回模板默认数据。 */}
{form.html_reference_favorite_id.startsWith("tpl:") &&
selectedTplRef?.convertible !== false && (
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">抽取信息写回模板数据</p>
<p className="text-muted-foreground text-xs">
开启后执行时会把按任务要求抽取到的信息写回所选模板的默认数据(data.json):本次页面、模板管理页预览与后续任务都基于更新后的数据。系统名称等系统设置不受影响。
</p>
</div>
<Switch
checked={form.html_template_writeback}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, html_template_writeback: v }))
}
/>
</div>
)}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">生成模型</p> <p className="text-muted-foreground text-xs">生成模型</p>
<Select <Select
@@ -1,213 +0,0 @@
"use client";
/**
* 结构化模板「编辑数据」:按模块编辑 data.json(DATA 顶层键,中文显示名来自后端
* 按 MANIFEST.页面 解析)与 manifest.json(「系统设置」模块:系统名称/导航页面/
* 交互规则),保存整体 PUT 持久化到后台。
*
* 性能要点:每个模块的草稿就是一段 JSON 文本(字符串比较即脏检查,不在每次按键
* 时对整份数据做 stringify/parse);校验只在输入的模块上即时做,保存前再统一解析。
* MANIFEST 仅在修改过时随保存一起提交(未动不回写)。操作按钮放在顶部工具栏,
* 编辑区占满剩余高度、自身滚动。
*/
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button, Textarea } from "tdesign-react";
import {
useHtmlTemplateActions,
useHtmlTemplateData,
type HtmlTemplateModuleInfo,
} from "@/strategy-components/api/html-templates";
import { cn } from "@/lib/utils";
const MANIFEST_KEY = "__manifest__";
const MANIFEST_LABEL = "系统设置";
export default function HtmlTemplateDataEditor({
templateId,
}: {
templateId: string;
}) {
const actions = useHtmlTemplateActions();
const { data: payload, isLoading } = useHtmlTemplateData(templateId, true);
const [activeKey, setActiveKey] = useState<string | null>(null);
/** DATA 各模块一段 pretty JSON 文本;MANIFEST 单独一段。 */
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [manifestDraft, setManifestDraft] = useState("");
const [baselines, setBaselines] = useState<Record<string, string>>({});
const [manifestBaseline, setManifestBaseline] = useState("");
const [errors, setErrors] = useState<Record<string, string | null>>({});
const [manifestError, setManifestError] = useState<string | null>(null);
useEffect(() => {
if (!payload) return;
const next: Record<string, string> = {};
for (const m of payload.modules) {
next[m.key] = JSON.stringify(payload.data[m.key] ?? null, null, 2);
}
setDrafts(next);
setBaselines(next);
setErrors({});
const manifestText = JSON.stringify(payload.manifest ?? {}, null, 2);
setManifestDraft(manifestText);
setManifestBaseline(manifestText);
setManifestError(null);
setActiveKey((prev) => (prev != null && prev in next ? prev : (payload.modules[0]?.key ?? null)));
}, [payload]);
const modules: HtmlTemplateModuleInfo[] = useMemo(
() => [...(payload?.modules ?? []), { key: MANIFEST_KEY, label: MANIFEST_LABEL }],
[payload],
);
const moduleOf = (key: string) => modules.find((m) => m.key === key);
const activeText =
activeKey === MANIFEST_KEY ? manifestDraft : (activeKey ? (drafts[activeKey] ?? "") : "");
const activeError =
activeKey === MANIFEST_KEY ? manifestError : (activeKey ? errors[activeKey] : null);
const manifestDirty = manifestDraft !== manifestBaseline;
const dirty =
manifestDirty ||
modules.some(
(m) => m.key !== MANIFEST_KEY && (drafts[m.key] ?? "") !== (baselines[m.key] ?? ""),
);
const allValid =
!manifestError && modules.every((m) => m.key === MANIFEST_KEY || !errors[m.key]);
/** 模块条数徽标:直接取服务端数据的计数(保存后 refetch 自动更新)。 */
const countOf = useMemo(() => {
const counts: Record<string, string> = {};
for (const m of payload?.modules ?? []) {
const value = payload?.data[m.key];
counts[m.key] = Array.isArray(value) ? `${value.length} 条` : "对象";
}
counts[MANIFEST_KEY] = "配置";
return counts;
}, [payload]);
const handleInput = (key: string, text: string) => {
if (key === MANIFEST_KEY) {
setManifestDraft(text);
try {
JSON.parse(text);
setManifestError(null);
} catch (error) {
setManifestError(error instanceof Error ? error.message : String(error));
}
return;
}
setDrafts((prev) => ({ ...prev, [key]: text }));
try {
JSON.parse(text);
setErrors((prev) => ({ ...prev, [key]: null }));
} catch (error) {
setErrors((prev) => ({
...prev,
[key]: error instanceof Error ? error.message : String(error),
}));
}
};
const handleSave = async () => {
if (!payload) return;
if (!allValid) {
toast.error("存在 JSON 无效的模块,请先修正");
return;
}
const data: Record<string, unknown> = {};
for (const m of payload.modules) {
data[m.key] = JSON.parse(drafts[m.key] ?? "null");
}
const manifest = manifestDirty
? (JSON.parse(manifestDraft) as Record<string, unknown>)
: undefined;
try {
await actions.updateData.mutateAsync({ id: templateId, data, manifest });
toast.success(
manifestDirty
? "默认数据与系统设置已保存到后台,定时任务与预览即时生效"
: "默认数据已保存到后台(data.json),定时任务与预览即时生效",
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const discard = () => {
setDrafts(baselines);
setErrors({});
setManifestDraft(manifestBaseline);
setManifestError(null);
};
if (isLoading || !payload) {
return <div className="ht-preview-state">正在加载模板数据…</div>;
}
return (
<div className="ht-data">
<nav className="ht-data-nav">
{modules.map((m) => (
<button
key={m.key}
type="button"
className={cn("ht-data-nav-item", m.key === activeKey && "is-active")}
onClick={() => setActiveKey(m.key)}
>
<span className="ht-data-nav-label">
<span className="ht-data-nav-label-text">{m.label}</span>
<span className="ht-data-nav-count">{countOf[m.key] ?? "—"}</span>
</span>
<span className="ht-data-nav-key">
{m.key === MANIFEST_KEY ? "manifest.json" : m.key}
</span>
</button>
))}
</nav>
<div className="ht-data-main">
<div className="ht-data-toolbar">
<div className="ht-data-toolbar-title">
<b>{activeKey ? (moduleOf(activeKey)?.label ?? activeKey) : ""}</b>
<span>{activeKey === MANIFEST_KEY ? "manifest.json" : activeKey}</span>
</div>
<div className="ht-toolbar-gap" />
<span className={cn("ht-data-status", activeError ? "is-bad" : "is-ok")}>
{activeError ? `JSON 无效:${activeError}` : "JSON 有效"}
</span>
<Button
size="small"
variant="outline"
disabled={!dirty || actions.updateData.isPending}
onClick={discard}
>
放弃修改
</Button>
<Button
size="small"
theme="primary"
loading={actions.updateData.isPending}
disabled={!dirty || !allValid}
onClick={() => void handleSave()}
title="保存后持久化到后台;页面产物右下角的齿轮编辑器仅改浏览器本地,不落库"
>
保存到后台
</Button>
</div>
<div className="ht-data-scroll ht-data-json">
<Textarea
value={activeText}
onChange={(value) => {
if (activeKey) handleInput(activeKey, String(value ?? ""));
}}
spellCheck={false}
placeholder="模块 JSON…"
/>
</div>
</div>
</div>
);
}
@@ -1,568 +0,0 @@
"use client";
/**
* 结构化模板「字段与模块」页签:
*
* - 字段显示:按页面分组展示字段目录(fields.json,含中文说明),开关控制每个
* 字段在页面详情/分区中的显示与隐藏(按显示名全局生效);保存 = 隐藏名单整体
* 写入 manifest 的「字段显示」。
* - 自定义模块:自然语言描述 → 后台调用模型按模板主题生成 HTML 片段(服务端
* 消毒)→ 完成后自动加入列表(生成期间不阻塞界面,列表显示生成中占位卡);
* 也支持手动粘贴 HTML。保存进 manifest 的「自定义模块」,页面把它渲染到指定
* 页面(tab)底部;定时任务产物自动带上。
*/
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Eye, EyeOff, Loader2, MoreHorizontal, Pencil, Plus, Sparkles, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button, Dialog, Input, Select, Switch, Tag, Textarea } from "tdesign-react";
import {
generateCustomModuleDirect,
invalidateHtmlTemplateCaches,
updateHtmlTemplateCustomModules,
updateHtmlTemplateFieldVisibility,
useHtmlTemplateFields,
type HtmlTemplateCustomModule,
type HtmlTemplateFieldInfo,
} from "@/strategy-components/api/html-templates";
/** 预览用最小样式:复刻模板的关键类(panel/metric/chip 等),让片段所见即所得。 */
const PREVIEW_CSS = `
body{margin:0;background:#f5f5f6;color:#262626;font-family:"PingFang SC","Microsoft YaHei",sans-serif;padding:12px}
.panel{background:#fff;border:1px solid #e5e5e5;border-radius:16px;padding:18px;margin-bottom:14px}
.panel-title{font-weight:800;margin-bottom:14px;display:flex;align-items:center}
.panel-title:before{content:"";width:5px;height:18px;background:#c8102e;border-radius:5px;margin-right:9px}
.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.metric{background:#fff;border:1px solid #e5e5e5;border-radius:16px;padding:17px}
.metric-label{font-size:13px;color:#737373}.metric-value{font-size:30px;font-weight:800;color:#c8102e;margin-top:8px}
.metric-unit{font-size:12px;margin-left:4px}.metric-note{font-size:12px;color:#888;margin-top:6px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.chip{display:inline-flex;border-radius:999px;padding:4px 9px;background:#f3f3f3;color:#555;font-size:11px;margin:2px}
.chip-red{background:#fff1f3;color:#c8102e}.chip-green{background:#edf9f2;color:#168a4a}.chip-orange{background:#fff6e8;color:#c56a00}
.text-list{display:grid;gap:9px}.text-item{padding:10px 12px;border-left:4px solid #e5a6b1;background:#fff8f9;border-radius:7px;font-size:13px}
.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.info-item{background:#fafafa;border-radius:10px;padding:11px}.info-item label{display:block;font-size:11px;color:#888;margin-bottom:5px}
.bar-row{display:grid;grid-template-columns:150px 1fr 38px;gap:10px;align-items:center;font-size:12px;margin:10px 0}
.bar{height:9px;background:#f1f1f1;border-radius:20px;overflow:hidden}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#c8102e,#ed6b7e);border-radius:20px}
`;
function previewDoc(fragment: string): string {
return `<!doctype html><html><head><meta charset="utf-8"><style>${PREVIEW_CSS}</style></head><body>${fragment}</body></html>`;
}
/** 后台生成任务(不阻塞界面,完成后自动加入模块草稿)。 */
interface GenJob {
id: string;
page: string;
title: string;
description: string;
}
type DialogMode = "ai" | "manual" | "edit";
export default function HtmlTemplatePageConfig({ templateId }: { templateId: string }) {
const queryClient = useQueryClient();
const { data: payload, isLoading } = useHtmlTemplateFields(templateId, true);
// 字段可见性草稿:隐藏名单(Set);抽取锁定草稿:不接受抽取更新的字段名单
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [extractLocked, setExtractLocked] = useState<Set<string>>(new Set());
// 自定义模块草稿 + 后台生成任务
const [modulesDraft, setModulesDraft] = useState<HtmlTemplateCustomModule[]>([]);
const [jobs, setJobs] = useState<GenJob[]>([]);
const [dirtyModules, setDirtyModules] = useState(false);
const [savingFields, setSavingFields] = useState(false);
const [savingModules, setSavingModules] = useState(false);
// 弹窗(ai 生成 / 手动粘贴 / 编辑既有模块)
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<DialogMode>("ai");
const [editIndex, setEditIndex] = useState<number | null>(null);
const [form, setForm] = useState({ page: "", title: "", description: "" });
const [htmlText, setHtmlText] = useState("");
useEffect(() => {
if (!payload) return;
setHidden(new Set(payload.fields.filter((f) => !f.visible).map((f) => f.label)));
setExtractLocked(new Set(payload.fields.filter((f) => !f.extract).map((f) => f.label)));
setModulesDraft(payload.customModules);
setDirtyModules(false);
}, [payload]);
const pages = payload?.pages ?? [];
const pageLabel = (key: string) => pages.find((p) => p.key === key)?.label ?? key;
/** 按字段主页面分组,组序跟随 manifest.页面。 */
const groups = useMemo(() => {
const order = pages.map((p) => p.label);
const map = new Map<string, HtmlTemplateFieldInfo[]>();
for (const f of payload?.fields ?? []) {
const key = f.pages[0] ?? "其它";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(f);
}
const sortedKeys = [...map.keys()].sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
return sortedKeys
.map((k) => [k, map.get(k)!] as const)
.filter(([, list]) => list.length > 0);
}, [payload, pages]);
const fieldsDirty = useMemo(() => {
if (!payload) return false;
const serverHidden = new Set(payload.fields.filter((f) => !f.visible).map((f) => f.label));
const serverLocked = new Set(payload.fields.filter((f) => !f.extract).map((f) => f.label));
return (
serverHidden.size !== hidden.size ||
[...serverHidden].some((label) => !hidden.has(label)) ||
serverLocked.size !== extractLocked.size ||
[...serverLocked].some((label) => !extractLocked.has(label))
);
}, [payload, hidden, extractLocked]);
const toggleField = (label: string, visible: boolean) => {
setHidden((prev) => {
const next = new Set(prev);
if (visible) next.delete(label);
else next.add(label);
return next;
});
};
const handleSaveFields = async () => {
setSavingFields(true);
try {
await updateHtmlTemplateFieldVisibility(templateId, [...hidden], [...extractLocked]);
toast.success("字段显示设置已保存,页面预览与定时任务产物即时生效");
await invalidateHtmlTemplateCaches(queryClient);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSavingFields(false);
}
};
const handleSaveModules = async () => {
setSavingModules(true);
try {
await updateHtmlTemplateCustomModules(templateId, modulesDraft);
toast.success("自定义模块已保存,页面预览与定时任务产物即时生效");
await invalidateHtmlTemplateCaches(queryClient);
setDirtyModules(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSavingModules(false);
}
};
/** AI 后台生成:点完立即关弹窗,完成后自动加入列表并提示。 */
const startBackgroundGeneration = () => {
if (!form.page) {
toast.error("请选择挂载页面");
return;
}
if (form.description.trim().length < 4) {
toast.error("请先描述想要新增的展示内容");
return;
}
const job: GenJob = {
id: `job-${Date.now()}`,
page: form.page,
title: form.title.trim() || "自定义模块",
description: form.description.trim(),
};
setJobs((prev) => [...prev, job]);
setDialogOpen(false);
toast.info("已在后台开始生成,完成后会自动加入列表");
generateCustomModuleDirect(templateId, {
page: job.page,
title: job.title,
description: job.description,
})
.then((html) => {
setModulesDraft((prev) => [
...prev,
{ 编号: job.id, 页面: job.page, 标题: job.title, html },
]);
setDirtyModules(true);
toast.success(`「${job.title}」已生成并加入列表,预览确认后点「保存模块列表」`);
})
.catch((error) => {
toast.error(`「${job.title}」生成失败:` + (error instanceof Error ? error.message : String(error)));
})
.finally(() => {
setJobs((prev) => prev.filter((j) => j.id !== job.id));
});
};
const openDialog = (mode: DialogMode, index?: number) => {
setDialogMode(mode);
if (mode === "edit" && index != null) {
const m = modulesDraft[index];
setEditIndex(index);
setForm({ page: m.页面, title: m.标题 ?? "", description: "" });
setHtmlText(m.html);
} else {
setEditIndex(null);
setForm({ page: pages[0]?.key ?? "", title: "", description: "" });
setHtmlText("");
}
setDialogOpen(true);
};
/** 手动粘贴 / 编辑模式提交:直接写入草稿。 */
const submitManual = () => {
if (!form.page) {
toast.error("请选择挂载页面");
return;
}
const html = htmlText.trim();
if (!html) {
toast.error("请粘贴或填写模块 HTML");
return;
}
const item: HtmlTemplateCustomModule = {
编号: `custom-${Date.now()}`,
页面: form.page,
标题: form.title.trim() || "自定义模块",
html,
};
if (dialogMode === "edit" && editIndex != null) {
setModulesDraft((prev) =>
prev.map((m, i) => (i === editIndex ? { ...m, 页面: item.页面, 标题: item.标题, html: item.html } : m)),
);
toast.success("模块已更新,记得点「保存模块列表」");
} else {
setModulesDraft((prev) => [...prev, item]);
toast.success("已加入列表,记得点「保存模块列表」");
}
setDirtyModules(true);
setDialogOpen(false);
};
if (isLoading || !payload) {
return <div className="ht-preview-state">正在加载字段目录…</div>;
}
return (
<div className="ht-pageconfig">
{/* ── 字段显示 ── */}
<section className="ht-pc-block">
<header className="ht-pc-block-head">
<div>
<h3>字段显示</h3>
<p>「显示」控制字段在页面上的渲染;「抽取」控制定时任务抽取的数据是否更新该字段(关闭 = 保持模板现有数据,默认开启)。按字段名全局生效,同名字段一起切换。</p>
</div>
<div className="ht-pc-block-ops">
<span className="ht-pane-hint">
{payload.fields.length - hidden.size} / {payload.fields.length} 显示中
</span>
<Button size="small" variant="outline" onClick={() => setHidden(new Set())}>
全部显示
</Button>
<Button
size="small"
theme="primary"
loading={savingFields}
disabled={!fieldsDirty}
onClick={() => void handleSaveFields()}
>
保存字段设置
</Button>
</div>
</header>
<div className="ht-pc-scroll">
{groups.map(([group, fields]) => (
<div className="ht-pc-group" key={group}>
<div className="ht-pc-group-title">{group}</div>
<div className="ht-pc-fields">
{fields.map((f) => (
<FieldCard
key={f.label}
field={f}
visible={!hidden.has(f.label)}
extract={!extractLocked.has(f.label)}
onToggleVisible={(v) => toggleField(f.label, v)}
onToggleExtract={(v) => {
setExtractLocked((prev) => {
const next = new Set(prev);
if (v) next.delete(f.label);
else next.add(f.label);
return next;
});
}}
/>
))}
</div>
</div>
))}
</div>
</section>
{/* ── 自定义模块 ── */}
<section className="ht-pc-block">
<header className="ht-pc-block-head">
<div>
<h3>自定义模块</h3>
<p>用一段自然语言描述想新增的展示内容,AI 在后台按模板主题生成紧凑模块(指标卡/短条目,不写大段文字);也支持手动粘贴 HTML。</p>
</div>
<div className="ht-pc-block-ops">
<Button size="small" variant="outline" onClick={() => openDialog("manual")}>
<Plus size={14} /> 粘贴 HTML
</Button>
<Button
size="small"
variant="outline"
theme="primary"
onClick={() => openDialog("ai")}
>
<Sparkles size={14} /> AI 生成模块
</Button>
<Button
size="small"
theme="primary"
loading={savingModules}
disabled={!dirtyModules}
onClick={() => void handleSaveModules()}
>
保存模块列表
</Button>
</div>
</header>
<div className="ht-pc-scroll">
{modulesDraft.length === 0 && jobs.length === 0 && (
<div className="ht-list-empty">
暂无自定义模块,点击「AI 生成模块」用一句话描述想要的展示内容。
</div>
)}
{jobs.map((job) => (
<div className="ht-pc-module ht-pc-job" key={job.id}>
<div className="ht-pc-module-head">
<Loader2 size={14} className="ht-spin" />
<b>{job.title}</b>
<Tag size="small" variant="light">
挂载:{pageLabel(job.page)}
</Tag>
<span className="ht-pane-hint">AI 生成中,完成后自动加入…</span>
</div>
<div className="ht-pc-job-desc">{job.description}</div>
</div>
))}
{modulesDraft.map((m, i) => (
<div className="ht-pc-module" key={m.编号 ?? i}>
<div className="ht-pc-module-head">
<b>{m.标题 || "自定义模块"}</b>
<Tag size="small" variant="light">
挂载:{pageLabel(m.页面)}
</Tag>
<div className="ht-ops-gap" />
<Button size="small" variant="text" onClick={() => openDialog("edit", i)}>
<Pencil size={14} /> 编辑
</Button>
<Button
size="small"
variant="text"
theme="danger"
onClick={() => {
setModulesDraft((prev) => prev.filter((_, j) => j !== i));
setDirtyModules(true);
}}
>
<Trash2 size={14} /> 删除
</Button>
</div>
<iframe
title={`模块预览 ${m.标题 ?? i}`}
srcDoc={previewDoc(m.html)}
sandbox="allow-same-origin"
className="ht-pc-module-preview"
/>
</div>
))}
</div>
</section>
{/* 模块弹窗(AI 生成 / 手动粘贴 / 编辑) */}
<Dialog
className="ht-create-dialog"
visible={dialogOpen}
onClose={() => setDialogOpen(false)}
header={
dialogMode === "ai"
? "AI 生成自定义模块"
: dialogMode === "manual"
? "粘贴 HTML 新增模块"
: "编辑自定义模块"
}
width={720}
footer={
dialogMode === "ai" ? (
<>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button theme="primary" onClick={startBackgroundGeneration}>
<Sparkles size={14} /> 后台生成
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button theme="primary" onClick={submitManual}>
{dialogMode === "edit" ? "保存修改" : "添加到列表"}
</Button>
</>
)
}
>
<div className="ht-create-grid">
<div className="ht-create-row">
<label className="ht-create-field">
<span>挂载页面</span>
<Select
value={form.page}
options={pages.map((p) => ({ label: p.label, value: p.key }))}
onChange={(v) => setForm((s) => ({ ...s, page: String(v ?? "") }))}
placeholder="选择挂载的页面"
/>
</label>
<label className="ht-create-field">
<span>模块标题(可选)</span>
<Input
value={form.title}
onChange={(v) => setForm((s) => ({ ...s, title: String(v ?? "") }))}
placeholder="例如:近期动态速览"
/>
</label>
</div>
{dialogMode === "ai" ? (
<label className="ht-create-field">
<span>想新增什么内容?(AI 会生成紧凑的指标卡/短条目模块,不会写大段文字)</span>
<Textarea
value={form.description}
onChange={(v) => setForm((s) => ({ ...s, description: String(v ?? "") }))}
placeholder="例如:三张指标卡——本季度事件 42 起、同比 +18%、重点区域 5 个;下面 3 条最新动态短条目。"
autosize={{ minRows: 3, maxRows: 6 }}
/>
</label>
) : (
<>
<label className="ht-create-field">
<span>模块 HTML(最外层一个 div;禁止 script/事件属性)</span>
<Textarea
value={htmlText}
onChange={(v) => setHtmlText(String(v ?? ""))}
spellCheck={false}
placeholder='<div class="panel">…</div>'
autosize={{ minRows: 6, maxRows: 14 }}
className="ht-mono"
/>
</label>
{htmlText.trim() && (
<>
<div className="ht-pc-module-head">
<b>预览</b>
</div>
<iframe
title="模块预览"
srcDoc={previewDoc(htmlText)}
sandbox="allow-same-origin"
className="ht-pc-module-preview"
/>
</>
)}
</>
)}
</div>
</Dialog>
</div>
);
}
/** 单个字段卡:右上角「⋮」菜单里放「页面显示 / 接受抽取更新」两个选项。 */
function FieldCard({
field,
visible,
extract,
onToggleVisible,
onToggleExtract,
}: {
field: HtmlTemplateFieldInfo;
visible: boolean;
extract: boolean;
onToggleVisible: (v: boolean) => void;
onToggleExtract: (v: boolean) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (!menuOpen) return;
const close = (ev: MouseEvent) => {
if (!(ev.target as HTMLElement | null)?.closest?.(".ht-pc-field")) setMenuOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [menuOpen]);
return (
<div className={"ht-pc-field" + (visible ? "" : " is-off")}>
<div className="ht-pc-field-copy">
<span className="ht-pc-field-label">
{visible ? <Eye size={13} /> : <EyeOff size={13} />}
{field.label}
{field.pages.length > 1 && (
<span className="ht-pc-field-pages">也在 {field.pages.slice(1).join("、")}</span>
)}
{!field.hasKey && <span className="ht-pc-tag is-layout">布局分区</span>}
{!visible && <span className="ht-pc-tag is-off">已隐藏</span>}
{!extract && <span className="ht-pc-tag is-lock">抽取已锁定</span>}
</span>
<span className="ht-pc-field-desc">{field.description}</span>
</div>
<div className="ht-pc-field-menu">
<button
type="button"
className={"ht-pc-kebab" + (menuOpen ? " is-open" : "")}
aria-label="字段选项"
title="字段选项"
onClick={() => setMenuOpen((v) => !v)}
>
<MoreHorizontal size={15} />
</button>
{menuOpen && (
<div className="ht-pc-menu" onClick={(e) => e.stopPropagation()}>
<div className="ht-pc-menu-row">
<div className="ht-pc-menu-copy">
<b>页面显示</b>
<span>{visible ? "当前显示;关闭后页面不再渲染该字段" : "当前隐藏;开启后页面恢复渲染该字段"}</span>
</div>
<Switch value={visible} onChange={(v) => onToggleVisible(!!v)} />
</div>
<div className="ht-pc-menu-row">
<div className="ht-pc-menu-copy">
<b>接受抽取更新</b>
<span>
{field.hasKey
? extract
? "定时任务抽取的数据会更新该字段(默认)"
: "已锁定:抽取数据不覆盖,保持模板现有数据"
: "布局分区无独立数据字段,开关状态仅作标记"}
</span>
</div>
<Switch value={extract} onChange={(v) => onToggleExtract(!!v)} />
</div>
</div>
)}
</div>
</div>
);
}
-962
View File
@@ -1,962 +0,0 @@
/* ============================================================
HTML 模板管理 —— 与任务分析定时(task-analysis.css)同一套
浅红色 UI 规范主题。ht- 前缀 + 复用 --mat-* 变量命名。
============================================================ */
.ht-page,
.ht-create-dialog {
--zs-001: #cf1322;
--zs-001-02: #fef6f6;
--zs-001-03: #feeded;
--zs-001-04: #fddadc;
--zs-001-09: #f5222d;
--zs-001-10: #bc0d1e;
--zs-001-11: #a8071a;
--fs-001-03: #fff6ea;
--fs-001-09: #ff9900;
--fs-001-10: #c66d00;
--fs-002-02: #f0faf6;
--fs-002-03: #eefcf7;
--fs-002-09: #2fd69a;
--fs-002-10: #189d72;
--fs-002-11: #0e6650;
--fs-003-03: #effafe;
--fs-003-09: #45c3f3;
--fs-004: #222222;
--fs-004-02: #fafafa;
--fs-004-03: #f5f5f5;
--fs-004-04: #e8e8e8;
--fs-004-05: #dbdbdb;
--fs-004-06: #c1c1c1;
--fs-004-07: #a6a6a6;
--fs-004-08: #7f7f7f;
--fs-004-09: #575757;
--fs-004-10: #3d3d3d;
--mat-primary: var(--zs-001-09);
--mat-primary-deep: var(--zs-001);
--mat-primary-light: var(--zs-001-02);
--mat-ink: var(--fs-004);
--mat-ink-soft: var(--fs-004-09);
--mat-ink-muted: var(--fs-004-07);
--mat-canvas: var(--fs-004-02);
--mat-panel: #ffffff;
--mat-line: var(--fs-004-04);
--mat-line-strong: var(--fs-004-05);
--mat-shadow-1: 0 2px 8px rgba(34, 34, 34, 0.08);
--mat-shadow-2: 0 4px 16px rgba(34, 34, 34, 0.12);
--mat-font: "Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC",
"PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--mat-font-mono: ui-monospace, SFMono-Regular, Consolas, "Courier New", monospace;
--td-brand-color: #f5222d;
--td-brand-color-hover: #cf1322;
--td-brand-color-focus: #feeded;
--td-brand-color-active: #a8071a;
--td-brand-color-disabled: #fa9196;
--td-brand-color-light: #fef6f6;
--td-brand-color-light-hover: #feeded;
}
.ht-create-dialog {
color: var(--mat-ink);
font-family: var(--mat-font);
}
.ht-page {
background: var(--mat-canvas);
color: var(--mat-ink);
display: flex;
flex-direction: column;
font-family: var(--mat-font);
/* 撑满外层面板(官方入口 .official-main-panel 有确定高度)且自身不滚动:
页头固定,左列/预览/编辑区各自内部滚动。 */
height: 100%;
min-height: 0;
overflow: hidden;
}
.ht-page *,
.ht-create-dialog * {
box-sizing: border-box;
}
/* TDesign 控件圆角统一 2px(与 ta- 规范一致) */
.ht-page .t-button,
.ht-create-dialog .t-button,
.ht-page .t-input,
.ht-create-dialog .t-input,
.ht-page .t-textarea__inner,
.ht-create-dialog .t-textarea__inner,
.ht-page .t-select,
.ht-create-dialog .t-select,
.ht-page .t-tag,
.ht-create-dialog .t-tag,
.ht-page .t-radio-group,
.ht-page .t-pagination {
border-radius: 2px;
}
/* ---------- 页头 ---------- */
.ht-page-head {
align-items: flex-end;
background: var(--mat-panel);
border-bottom: 1px solid var(--mat-line);
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
padding: 24px 24px 20px;
}
.ht-page-head h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.2px;
margin: 0 0 4px;
}
.ht-page-head-copy > p {
color: var(--mat-ink-soft);
font-size: 14px;
line-height: 1.6;
margin: 0;
max-width: 720px;
}
.ht-page-head-actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: 8px;
}
.ht-page-head .t-button__text {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* ---------- 主体:左列表 + 右详情 ---------- */
.ht-body {
display: flex;
flex: 1;
gap: 16px;
min-height: 0;
padding: 20px 24px 24px;
}
.ht-list {
background: var(--mat-panel);
border: 1px solid var(--mat-line);
border-radius: 4px;
box-shadow: var(--mat-shadow-1);
display: flex;
flex-direction: column;
min-height: 0;
width: 296px;
}
.ht-list-search {
border-bottom: 1px solid var(--mat-line);
padding: 12px;
}
.ht-list-items {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
.ht-tpl-item {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
text-align: left;
transition: background 120ms ease, border-color 120ms ease;
width: 100%;
}
.ht-tpl-item:hover {
background: var(--fs-004-02);
}
.ht-tpl-item.is-active {
background: var(--mat-primary-light);
border-color: var(--zs-001-04);
}
.ht-tpl-item-top {
align-items: center;
display: flex;
gap: 8px;
min-width: 0;
}
.ht-tpl-item-top svg {
color: var(--fs-004-07);
flex-shrink: 0;
}
.ht-tpl-item.is-active .ht-tpl-item-top svg {
color: var(--mat-primary);
}
.ht-tpl-item-name {
flex: 1;
font-size: 14px;
font-weight: 600;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ht-tpl-item.is-active .ht-tpl-item-name {
color: var(--mat-primary-deep);
}
.ht-tpl-item-meta {
color: var(--fs-004-07);
display: flex;
font-size: 11px;
gap: 8px;
}
.ht-tpl-item-meta .ht-dot {
background: var(--fs-002-09);
border-radius: 50%;
display: inline-block;
height: 6px;
width: 6px;
}
.ht-tpl-item-meta .ht-dot.is-struct {
background: var(--fs-003-09);
}
.ht-list-empty {
color: var(--fs-004-07);
font-size: 13px;
padding: 20px 12px;
text-align: center;
}
/* ---------- 详情面板 ---------- */
.ht-detail {
background: var(--mat-panel);
border: 1px solid var(--mat-line);
border-radius: 4px;
box-shadow: var(--mat-shadow-1);
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.ht-detail-head {
border-bottom: 1px solid var(--mat-line);
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 14px;
}
.ht-detail-tags {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ht-detail-meta {
color: var(--fs-004-07);
font-size: 12px;
margin-left: 4px;
}
.ht-detail-form {
align-items: flex-end;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.ht-detail-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ht-detail-field > span {
color: var(--mat-ink-muted);
font-size: 12px;
}
.ht-detail-ops {
align-items: center;
border-top: 1px solid var(--mat-line);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 12px;
}
.ht-detail-ops .ht-ops-gap {
flex: 1;
}
.ht-detail-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
/* TDesign Tabs 适配 */
.ht-detail-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.ht-detail-tabs .t-tabs__content {
flex: 1;
min-height: 0;
padding: 0;
}
.ht-detail-tabs .t-tab-panel {
/* 面板是 TDesign 的普通块级容器:改为受约束的 flex 列,让预览/编辑数据/编辑 HTML
各自的内部滚动生效(否则内容把面板撑高,左侧模块导航永远不溢出、无滚动条)。 */
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ht-detail-tabs .t-tabs__header {
border-bottom: 1px solid var(--mat-line);
padding: 0 20px;
}
.ht-detail-tabs .t-tabs__nav-item {
border-radius: 0;
color: var(--mat-ink);
font-size: 14px;
font-weight: 500;
}
.ht-detail-tabs .t-tabs__nav-item:hover {
color: var(--mat-primary);
}
.ht-detail-tabs .t-tabs__nav-item.t-is-active {
color: var(--mat-primary);
font-weight: 600;
}
.ht-detail-tabs .t-tabs__nav-item.t-is-active::after {
background: var(--mat-primary);
height: 2px;
}
.ht-detail-tabs .ht-tab-label {
align-items: center;
display: inline-flex;
gap: 5px;
}
.ht-spin {
animation: ht-rotate 1s linear infinite;
}
@keyframes ht-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 预览 */
.ht-preview-pane {
background: #fff;
height: 100%;
min-height: 0;
}
.ht-preview-pane iframe {
border: 0;
display: block;
height: 100%;
width: 100%;
}
.ht-preview-state {
align-items: center;
color: var(--fs-004-07);
display: flex;
font-size: 13px;
height: 100%;
justify-content: center;
}
/* 编辑 HTML */
.ht-html-pane {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 16px 20px;
}
.ht-html-pane textarea {
font-family: var(--mat-font-mono);
font-size: 12px;
line-height: 1.6;
min-height: 0;
resize: none;
}
/* TDesign Textarea 外层容器参与 flex 拉伸,编辑框占满面板剩余高度 */
.ht-html-pane .t-textarea {
display: flex;
flex: 1;
min-height: 0;
}
.ht-html-pane .t-textarea__inner {
flex: 1;
height: 100%;
min-height: 0;
}
.ht-pane-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
}
.ht-pane-hint {
color: var(--fs-004-07);
font-size: 12px;
}
.ht-pane-hint code {
background: var(--fs-004-03);
border-radius: 2px;
font-family: var(--mat-font-mono);
font-size: 11px;
padding: 1px 5px;
}
/* ---------- 编辑数据(友好化) ---------- */
.ht-data {
display: flex;
flex: 1;
gap: 0;
min-height: 0;
}
.ht-data-nav {
border-right: 1px solid var(--mat-line);
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
padding: 12px;
width: 208px;
}
.ht-data-nav-item {
align-items: center;
background: transparent;
border: 0;
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
padding: 9px 12px;
text-align: left;
transition: background 120ms ease;
}
.ht-data-nav-item:hover {
background: var(--fs-004-02);
}
.ht-data-nav-item.is-active {
background: var(--mat-primary-light);
}
.ht-data-nav-label {
align-items: center;
color: var(--mat-ink);
display: flex;
font-size: 13px;
font-weight: 600;
gap: 6px;
width: 100%;
}
.ht-data-nav-label-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ht-data-nav-item.is-active .ht-data-nav-label {
color: var(--mat-primary-deep);
}
.ht-data-nav-count {
background: var(--fs-004-03);
border-radius: 999px;
color: var(--fs-004-08);
font-size: 10px;
font-weight: 500;
line-height: 1;
padding: 3px 7px;
}
.ht-data-nav-item.is-active .ht-data-nav-count {
background: var(--zs-001-04);
color: var(--mat-primary-deep);
}
.ht-data-nav-key {
color: var(--fs-004-07);
font-family: var(--mat-font-mono);
font-size: 10px;
}
.ht-data-main {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.ht-data-toolbar {
align-items: center;
border-bottom: 1px solid var(--mat-line);
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
}
.ht-data-toolbar-title {
align-items: baseline;
display: flex;
gap: 8px;
}
.ht-data-toolbar-title b {
font-size: 14px;
}
.ht-data-toolbar-title span {
color: var(--fs-004-07);
font-family: var(--mat-font-mono);
font-size: 11px;
}
.ht-data-toolbar .ht-toolbar-gap {
flex: 1;
}
.ht-data-status {
border-radius: 999px;
font-size: 11px;
padding: 3px 10px;
}
.ht-data-status.is-ok {
background: var(--fs-002-03);
color: var(--fs-002-10);
}
.ht-data-status.is-bad {
background: var(--zs-001-03);
color: var(--mat-primary-deep);
}
.ht-data-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.ht-data-json {
display: flex;
overflow: hidden; /* 容器不滚,textarea 自己滚 */
padding: 12px 16px 16px;
}
/* TDesign Textarea 外层参与 flex 拉伸,内层 textarea 撑满高度 */
.ht-data-json .t-textarea {
display: flex;
flex: 1;
min-height: 0;
}
.ht-data-json textarea {
flex: 1;
height: 100%;
min-height: 0;
resize: none;
font-family: var(--mat-font-mono);
font-size: 12px;
line-height: 1.6;
}
/* ---------- 字段与模块(页面配置页签) ---------- */
.ht-pageconfig {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
overflow-y: auto;
padding: 16px 20px;
}
.ht-pc-block {
background: var(--fs-004-02);
border: 1px solid var(--mat-line);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.ht-pc-block-head {
align-items: center;
border-bottom: 1px solid var(--mat-line);
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
padding: 14px 16px;
}
.ht-pc-block-head h3 {
font-size: 14px;
margin: 0 0 4px;
}
.ht-pc-block-head p {
color: var(--fs-004-08);
font-size: 12px;
line-height: 1.6;
margin: 0;
max-width: 620px;
}
.ht-pc-block-ops {
align-items: center;
display: flex;
gap: 8px;
}
.ht-pc-scroll {
padding: 12px 16px 16px;
}
.ht-pc-group {
margin-bottom: 14px;
}
.ht-pc-group-title {
color: var(--mat-ink);
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}
.ht-pc-group-title::before {
background: var(--mat-primary);
border-radius: 2px;
content: "";
display: inline-block;
height: 13px;
margin-right: 7px;
vertical-align: -2px;
width: 3px;
}
.ht-pc-fields {
align-items: start; /* 卡片按各自内容高度,不随行内最高卡拉伸(避免卡下大片空白) */
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
}
.ht-pc-field {
align-items: center;
align-self: start; /* 双保险:即使 grid 对齐被覆盖也不被行内拉伸 */
background: var(--mat-panel);
border: 1px solid var(--mat-line);
border-radius: 4px;
display: flex;
gap: 10px;
height: fit-content;
padding: 9px 12px;
}
.ht-pc-field.is-off {
background: var(--fs-004-03);
}
.ht-pc-field-copy {
flex: 1;
min-width: 0;
}
.ht-pc-field-label {
align-items: center;
color: var(--mat-ink);
display: flex;
font-size: 13px;
font-weight: 600;
gap: 6px;
}
.ht-pc-field-label svg {
color: var(--fs-004-07);
flex-shrink: 0;
}
.ht-pc-field.is-off .ht-pc-field-label {
color: var(--fs-004-08);
}
.ht-pc-field-pages {
color: var(--fs-004-07);
font-size: 10px;
font-weight: 400;
}
.ht-pc-field-desc {
color: var(--fs-004-08);
display: block;
font-size: 11px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ht-pc-module {
background: var(--mat-panel);
border: 1px solid var(--mat-line);
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
}
.ht-pc-module-head {
align-items: center;
border-bottom: 1px solid var(--mat-line);
display: flex;
gap: 10px;
padding: 9px 12px;
}
.ht-pc-module-head b {
font-size: 13px;
}
.ht-pc-module-preview {
background: var(--fs-004-02);
border: 0;
display: block;
height: 220px;
width: 100%;
}
.ht-mono textarea {
font-family: var(--mat-font-mono);
font-size: 11px;
}
/* ---------- 新建弹窗 ---------- */
.ht-create-dialog .t-dialog__body {
padding-top: 8px;
}
.ht-create-grid {
display: grid;
gap: 12px;
}
.ht-create-row {
display: grid;
gap: 12px;
grid-template-columns: 1fr 1fr;
}
.ht-create-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ht-create-field > span {
color: var(--mat-ink-muted);
font-size: 12px;
}
.ht-create-field textarea {
font-family: var(--mat-font-mono);
font-size: 12px;
}
@media (max-width: 1100px) {
.ht-body {
flex-direction: column;
}
.ht-list {
width: 100%;
max-height: 264px;
}
.ht-list-items {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
}
.ht-create-row {
grid-template-columns: 1fr;
}
}
/* 后台生成任务占位卡 */
.ht-pc-job .ht-pc-job-desc {
color: var(--fs-004-08);
font-size: 12px;
padding: 10px 12px 12px;
}
.ht-pc-job svg {
color: var(--mat-primary);
}
/* 字段卡 ⋮ 菜单与状态标签 */
.ht-pc-field-menu {
position: relative;
flex-shrink: 0;
}
.ht-pc-kebab {
align-items: center;
background: transparent;
border: 0;
border-radius: 4px;
color: var(--fs-004-07);
cursor: pointer;
display: inline-flex;
height: 28px;
justify-content: center;
width: 28px;
}
.ht-pc-kebab:hover,
.ht-pc-kebab.is-open {
background: var(--fs-004-03);
color: var(--mat-ink);
}
.ht-pc-menu {
background: var(--mat-panel);
border: 1px solid var(--mat-line);
border-radius: 4px;
box-shadow: var(--mat-shadow-2);
min-width: 250px;
padding: 6px;
position: absolute;
right: 0;
top: 30px;
z-index: 30;
}
.ht-pc-menu-row {
align-items: center;
border-radius: 4px;
display: flex;
gap: 12px;
justify-content: space-between;
padding: 8px 10px;
}
.ht-pc-menu-row + .ht-pc-menu-row {
border-top: 1px solid var(--fs-004-03);
margin-top: 4px;
}
.ht-pc-menu-copy {
min-width: 0;
}
.ht-pc-menu-copy b {
color: var(--mat-ink);
display: block;
font-size: 13px;
}
.ht-pc-menu-copy span {
color: var(--fs-004-08);
display: block;
font-size: 11px;
line-height: 1.5;
margin-top: 2px;
}
.ht-pc-tag {
border-radius: 999px;
flex-shrink: 0;
font-size: 10px;
line-height: 1;
padding: 3px 8px;
}
.ht-pc-tag.is-off {
background: var(--fs-004-03);
color: var(--fs-004-08);
}
.ht-pc-tag.is-lock {
background: var(--fs-001-03);
color: var(--fs-001-10);
}
.ht-pc-tag.is-layout {
background: var(--fs-004-03);
color: var(--fs-004-07);
}
@@ -22,11 +22,9 @@ export interface HtmlTemplateMeta {
description: string; description: string;
/** 内置模板(不可删除,可编辑/上传覆盖/重置回随包原版)。 */ /** 内置模板(不可删除,可编辑/上传覆盖/重置回随包原版)。 */
builtin: boolean; builtin: boolean;
/** true = 支持「JSON 转换填充」;false = 只展示。 */ /** true = 带 `const DATA=` 注入行,可「JSON 转换填充」;false = 只展示。 */
convertible: boolean; convertible: boolean;
/** "structured" = 结构化模板(骨架+runtime+data.json,支持「编辑数据」持久化);缺省 single。 */ /** 正文大小(字节)。 */
kind?: "single" | "structured";
/** 正文大小(字节;结构化模板为各组成文件之和)。 */
size?: number; size?: number;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
@@ -38,7 +36,6 @@ interface ServerMeta {
description?: string | null; description?: string | null;
builtin?: boolean; builtin?: boolean;
convertible?: boolean; convertible?: boolean;
kind?: string | null;
size?: number | null; size?: number | null;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
@@ -51,7 +48,6 @@ function toMeta(s: ServerMeta): HtmlTemplateMeta {
description: s.description ?? "", description: s.description ?? "",
builtin: !!s.builtin, builtin: !!s.builtin,
convertible: !!s.convertible, convertible: !!s.convertible,
kind: s.kind === "structured" ? "structured" : "single",
size: s.size ?? undefined, size: s.size ?? undefined,
createdAt: s.created_at ?? null, createdAt: s.created_at ?? null,
updatedAt: s.updated_at ?? null, updatedAt: s.updated_at ?? null,
@@ -85,178 +81,14 @@ export async function listHtmlTemplates(): Promise<HtmlTemplateMeta[]> {
return templates; return templates;
} }
/** export async function getHtmlTemplateContent(templateId: string): Promise<string> {
* 模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物); const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/content`, {
* `raw: true` 返回正文原文(结构化模板 = 带 `__PAGE_*` 标记的骨架,编辑用)。 method: "GET",
*/ });
export async function getHtmlTemplateContent(
templateId: string,
opts?: { raw?: boolean },
): Promise<string> {
const suffix = opts?.raw ? "?raw=1" : "";
const res = await apiFetch(
`${BASE()}/${encodeURIComponent(templateId)}/content${suffix}`,
{ method: "GET" },
);
const data = await readJson<{ html: string }>(res, "get html template content"); const data = await readJson<{ html: string }>(res, "get html template content");
return data.html ?? ""; return data.html ?? "";
} }
/** 结构化模板的一个数据模块(DATA 顶层键)及其中文显示名。 */
export interface HtmlTemplateModuleInfo {
key: string;
label: string;
}
/** 结构化模板的默认数据 + MANIFEST + 模块中文名(管理页「编辑数据」用)。 */
export async function getHtmlTemplateData(
templateId: string,
): Promise<{
data: Record<string, unknown>;
manifest: Record<string, unknown>;
modules: HtmlTemplateModuleInfo[];
}> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/data`, {
method: "GET",
});
const payload = await readJson<{
data: Record<string, unknown>;
manifest?: Record<string, unknown> | null;
modules?: { key: string; label?: string }[] | null;
}>(res, "get html template data");
return {
data: payload.data ?? {},
manifest: payload.manifest ?? {},
modules: (payload.modules ?? []).map((m) => ({
key: m.key,
label: m.label || m.key,
})),
};
}
/** 字段目录的一条字段(fields.json × 当前可见性 × 抽取锁定)。 */
export interface HtmlTemplateFieldInfo {
label: string;
pages: string[];
description: string;
visible: boolean;
/** 是否接受定时任务抽取数据更新(默认 true;关闭后该字段保持模板现有数据)。 */
extract: boolean;
/** false = 布局分区(无独立数据键),抽取开关无意义。 */
hasKey: boolean;
}
/** 自定义模块(挂在指定页面底部的 AI 生成 HTML 片段,存于 manifest)。 */
export interface HtmlTemplateCustomModule {
编号?: string;
页面: string;
标题?: string;
html: string;
}
export interface HtmlTemplatePageOption {
key: string;
label: string;
}
/** 字段目录 + 页面清单 + 自定义模块列表(「字段与模块」页签数据源)。 */
export async function getHtmlTemplateFields(templateId: string): Promise<{
fields: HtmlTemplateFieldInfo[];
pages: HtmlTemplatePageOption[];
customModules: HtmlTemplateCustomModule[];
}> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/fields`, {
method: "GET",
});
const data = await readJson<{
fields?: (Omit<HtmlTemplateFieldInfo, "extract" | "hasKey"> & {
extract?: boolean | null;
has_key?: boolean | null;
})[] | null;
pages?: HtmlTemplatePageOption[] | null;
custom_modules?: HtmlTemplateCustomModule[] | null;
}>(res, "get html template fields");
return {
fields: (data.fields ?? []).map((f) => ({
...f,
extract: f.extract !== false,
// 兼容旧缓存缺 has_key 的响应:缺失按有键处理(宁可多开不可全禁)。
hasKey: f.has_key !== false,
})),
pages: data.pages ?? [],
customModules: data.custom_modules ?? [],
};
}
/** 整体替换字段隐藏名单(可选一并替换抽取锁定名单;未列出的字段全部显示/接受抽取)。 */
export async function updateHtmlTemplateFieldVisibility(
templateId: string,
hidden: string[],
extractLocked?: string[],
): Promise<HtmlTemplateMeta> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/fields`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
hidden,
...(extractLocked !== undefined ? { extract_locked: extractLocked } : {}),
}),
});
return toMeta(await readJson<ServerMeta>(res, "update html template fields"));
}
/** 自定义模块列表整体替换(后端逐条消毒后入库)。 */
export async function updateHtmlTemplateCustomModules(
templateId: string,
modules: HtmlTemplateCustomModule[],
): Promise<HtmlTemplateMeta> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/custom-modules`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modules }),
});
return toMeta(await readJson<ServerMeta>(res, "update html template custom modules"));
}
/** 按自然语言描述调用模型生成自定义模块 HTML 片段(已消毒,未入库)。 */
export async function generateHtmlTemplateCustomModule(
templateId: string,
input: { page: string; title?: string; description: string; modelName?: string },
): Promise<string> {
const res = await apiFetch(
`${BASE()}/${encodeURIComponent(templateId)}/custom-modules/generate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page: input.page,
title: input.title ?? "",
description: input.description,
model_name: input.modelName ?? null,
}),
},
);
const data = await readJson<{ html: string }>(res, "generate custom module");
return data.html ?? "";
}
/** 整体替换结构化模板的默认数据(manifest 给出时一并持久化 manifest.json)。 */
export async function updateHtmlTemplateData(
templateId: string,
data: Record<string, unknown>,
manifest?: Record<string, unknown>,
): Promise<HtmlTemplateMeta> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(templateId)}/data`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data,
...(manifest !== undefined ? { manifest } : {}),
}),
});
return toMeta(await readJson<ServerMeta>(res, "update html template data"));
}
export async function createHtmlTemplate(input: { export async function createHtmlTemplate(input: {
templateId: string; templateId: string;
name: string; name: string;
@@ -310,19 +142,6 @@ export async function resetHtmlTemplate(templateId: string): Promise<HtmlTemplat
export const HTML_TEMPLATES_KEY = "html-templates"; export const HTML_TEMPLATES_KEY = "html-templates";
const TEMPLATE_CONTENT_KEY = "html-template-content"; const TEMPLATE_CONTENT_KEY = "html-template-content";
const TEMPLATE_DATA_KEY = "html-template-data";
const TEMPLATE_FIELDS_KEY = "html-template-fields";
/** 失效模板相关的全部查询(列表/内容/数据/字段)——任何写操作后调用。 */
export function invalidateHtmlTemplateCaches(queryClient: {
invalidateQueries: (filter: { queryKey: unknown[] }) => Promise<unknown>;
}): Promise<unknown> {
return Promise.all(
[HTML_TEMPLATES_KEY, TEMPLATE_CONTENT_KEY, TEMPLATE_DATA_KEY, TEMPLATE_FIELDS_KEY].map(
(key) => queryClient.invalidateQueries({ queryKey: [key] }),
),
);
}
export function useHtmlTemplatesAdmin(enabled = true) { export function useHtmlTemplatesAdmin(enabled = true) {
return useQuery({ return useQuery({
@@ -334,10 +153,9 @@ export function useHtmlTemplatesAdmin(enabled = true) {
}); });
} }
/** 可预览的完整文档(结构化模板为默认数据组合产物)。 */
export function useHtmlTemplateContent(templateId: string | null) { export function useHtmlTemplateContent(templateId: string | null) {
return useQuery({ return useQuery({
queryKey: [TEMPLATE_CONTENT_KEY, templateId, "doc"], queryKey: [TEMPLATE_CONTENT_KEY, templateId],
queryFn: () => getHtmlTemplateContent(templateId as string), queryFn: () => getHtmlTemplateContent(templateId as string),
enabled: !!templateId, enabled: !!templateId,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
@@ -345,50 +163,11 @@ export function useHtmlTemplateContent(templateId: string | null) {
}); });
} }
/** 正文原文(结构化模板 = 带 `__PAGE_*` 标记的骨架,编辑用)。 */
export function useHtmlTemplateRawContent(templateId: string | null, enabled = true) {
return useQuery({
queryKey: [TEMPLATE_CONTENT_KEY, templateId, "raw"],
queryFn: () => getHtmlTemplateContent(templateId as string, { raw: true }),
enabled: !!templateId && enabled,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
/** 结构化模板的默认数据(仅 kind=structured 时启用)。 */
export function useHtmlTemplateData(templateId: string | null, enabled = true) {
return useQuery({
queryKey: [TEMPLATE_DATA_KEY, templateId],
queryFn: () => getHtmlTemplateData(templateId as string),
enabled: !!templateId && enabled,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
/** 字段目录 + 页面清单 + 自定义模块(「字段与模块」页签)。 */
export function useHtmlTemplateFields(templateId: string | null, enabled = true) {
return useQuery({
queryKey: [TEMPLATE_FIELDS_KEY, templateId],
queryFn: () => getHtmlTemplateFields(templateId as string),
enabled: !!templateId && enabled,
refetchOnWindowFocus: false,
staleTime: 30_000,
});
}
export function generateCustomModuleDirect(
templateId: string,
input: { page: string; title?: string; description: string; modelName?: string },
): Promise<string> {
return generateHtmlTemplateCustomModule(templateId, input);
}
export function useHtmlTemplateActions() { export function useHtmlTemplateActions() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const invalidate = () => { const invalidate = () => {
void invalidateHtmlTemplateCaches(queryClient); queryClient.invalidateQueries({ queryKey: [HTML_TEMPLATES_KEY] });
queryClient.invalidateQueries({ queryKey: [TEMPLATE_CONTENT_KEY] });
}; };
return { return {
create: useMutation({ mutationFn: createHtmlTemplate, onSuccess: invalidate }), create: useMutation({ mutationFn: createHtmlTemplate, onSuccess: invalidate }),
@@ -399,14 +178,6 @@ export function useHtmlTemplateActions() {
}) => updateHtmlTemplate(vars.id, vars.patch), }) => updateHtmlTemplate(vars.id, vars.patch),
onSuccess: invalidate, onSuccess: invalidate,
}), }),
updateData: useMutation({
mutationFn: (vars: {
id: string;
data: Record<string, unknown>;
manifest?: Record<string, unknown>;
}) => updateHtmlTemplateData(vars.id, vars.data, vars.manifest),
onSuccess: invalidate,
}),
remove: useMutation({ mutationFn: deleteHtmlTemplate, onSuccess: invalidate }), remove: useMutation({ mutationFn: deleteHtmlTemplate, onSuccess: invalidate }),
reset: useMutation({ mutationFn: resetHtmlTemplate, onSuccess: invalidate }), reset: useMutation({ mutationFn: resetHtmlTemplate, onSuccess: invalidate }),
}; };
@@ -5,17 +5,11 @@
读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置) 读对所有登录用户开放(定时任务表单下拉也要用),写(创建/编辑/上传覆盖/删除/重置)
仅管理员——与敏感词管理同一鉴权口径。旧端点 ``GET /api/scheduled-tasks/html-templates`` 仅管理员——与敏感词管理同一鉴权口径。旧端点 ``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 from __future__ import annotations
import asyncio import asyncio
import re
from typing import Any from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
@@ -23,7 +17,7 @@ from pydantic import BaseModel, Field
from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog from app.gateway.catalog_cache import cached_catalog, catalog_key, invalidate_catalog
from app.gateway.deps import get_optional_user_from_request from app.gateway.deps import get_optional_user_from_request
from deerflow.runtime.scheduler import template_fill, template_store from deerflow.runtime.scheduler import template_store
router = APIRouter(prefix="/api/html-templates", tags=["html-templates"]) router = APIRouter(prefix="/api/html-templates", tags=["html-templates"])
@@ -54,33 +48,6 @@ class TemplateUpdateRequest(BaseModel):
html: str | None = Field(default=None, description="新的模板正文(上传覆盖走这里)") 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("") @router.get("")
async def list_html_templates(request: Request) -> dict: async def list_html_templates(request: Request) -> dict:
"""模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。""" """模板清单(含 builtin/convertible/size/时间),供管理页与定时任务表单共用。"""
@@ -102,230 +69,15 @@ async def get_html_template(template_id: str, request: Request) -> dict[str, Any
@router.get("/{template_id}/content") @router.get("/{template_id}/content")
async def get_html_template_content( async def get_html_template_content(template_id: str, request: Request) -> dict:
template_id: str, request: Request, raw: bool = False
) -> dict:
"""模板内容。默认返回可预览的完整文档(结构化模板 = 默认数据组合产物);
``?raw=1`` 返回正文原文(结构化模板 = 带 __PAGE_* 标记的骨架,编辑用)。"""
async def load() -> dict: async def load() -> dict:
if raw:
try: try:
html = await asyncio.to_thread(template_store.read_template_html, template_id) html = await asyncio.to_thread(template_store.read_template_html, template_id)
except template_store.TemplateStoreError: except template_store.TemplateStoreError:
raise _not_found(template_id) 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} 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), load)
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. 只输出一个最外层 <div> 包裹的 HTML 片段,不要 <html>/<head>/<body>/<script>/<style>/<iframe>/<form>
2. 禁止任何 on* 事件属性、javascript: 链接、外部资源引用(图片/字体/CDN)
3. 内容用简体中文;基于下面的描述合理组织版式;没有的数据写「待补充」,不要编造精确数字
4. **文字极简(最重要)**:整个模块总文字量 ≤120 字;任何一条文字 ≤30 字;严禁出现大段连续正文或 <p> 长段落
5. **呈现方式**:数字/统计一律用 metric 指标卡(大数字+短标签+一句 ≤20 字注释);分类/状态用 chip 标签;多条信息用 text-item 短条目(每条一行短句);条目间用并列结构,不要写成文章
6. 只输出片段本身,不要任何解释文字或 markdown 代码块
挂载页面:{page}
模块标题:{title}
模块描述:{description}"""
_THINK_BLOCK_RE = re.compile(r"<think>.*?</think>", re.IGNORECASE | re.DOTALL)
_THINK_TAG_RE = re.compile(r"</?think>", re.IGNORECASE)
_FENCE_HTML_RE = re.compile(r"```(?:html)?\s*(.*?)```", re.DOTALL | re.IGNORECASE)
def _extract_fragment(raw: str) -> str:
text = str(raw or "").strip()
text = _THINK_BLOCK_RE.sub("", text)
text = _THINK_TAG_RE.sub("", text).strip()
fence = _FENCE_HTML_RE.search(text)
if fence:
text = fence.group(1).strip()
body = re.search(r"<body[^>]*>(.*?)</body>", text, re.IGNORECASE | re.DOTALL)
if body:
text = body.group(1).strip()
# 思考文字混在片段前(无标签包裹)时,从第一个 "<div" 起截取。
div_at = text.find("<div")
if div_at > 0:
text = text[div_at:].strip()
return text
@router.post("/{template_id}/custom-modules/generate")
async def generate_html_template_custom_module(
template_id: str, body: CustomModuleGenerateRequest, request: Request
) -> dict[str, Any]:
"""按用户的自然语言描述调用模型生成自定义模块 HTML 片段(已消毒,未入库)。"""
await _require_admin(request)
try:
# 模板必须存在且页面编号合法,防止生成挂在不存在的页面上。
manifest = await 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))
valid_pages = {
str(p.get("编号") or "") for p in manifest.get("页面") or [] if isinstance(p, dict)
}
if body.page not in valid_pages:
raise HTTPException(status_code=400, detail=f"未知页面编号:{body.page}")
from langchain_core.messages import HumanMessage
from deerflow.models import create_chat_model
prompt = _CUSTOM_MODULE_PROMPT.format(
page=body.page, title=body.title or "(由你起一个简短标题)", description=body.description
)
model = create_chat_model(name=body.model_name or None, thinking_enabled=False)
response = await model.ainvoke([HumanMessage(content=prompt)])
content = getattr(response, "content", response)
if isinstance(content, list):
content = "\n".join(
part.get("text", "") if isinstance(part, dict) else str(part) for part in content
)
fragment = template_store.sanitize_html_fragment(_extract_fragment(str(content)))
if "<div" not in fragment:
raise HTTPException(status_code=502, detail="模型未返回有效的 HTML 片段,请调整描述后重试")
return {"template_id": template_id, "page": body.page, "html": fragment}
@router.post("") @router.post("")
@@ -23,7 +23,7 @@ from deerflow.config.agents_config import AGENT_ID_PATTERN, load_agent_config, l
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime.scheduler import html_page as html_page_utils from deerflow.runtime.scheduler import html_page as html_page_utils
from deerflow.runtime.scheduler import template_fill, template_store from deerflow.runtime.scheduler import template_fill
from deerflow.runtime.scheduler.element import ElementDeliveryClient from deerflow.runtime.scheduler.element import ElementDeliveryClient
from deerflow.runtime.serialization import serialize_channel_values from deerflow.runtime.serialization import serialize_channel_values
from deerflow.runtime.user_context import reset_current_user, set_current_user from deerflow.runtime.user_context import reset_current_user, set_current_user
@@ -1636,10 +1636,6 @@ class ScheduledTaskService:
# 不影响 Markdown 生成等其它正常操作。缺省(未存该键的老任务)视为开启: # 不影响 Markdown 生成等其它正常操作。缺省(未存该键的老任务)视为开启:
# 模板有真实数据来源后,默认走「智能体检索 → 转换填充」,显式 False 才关闭。 # 模板有真实数据来源后,默认走「智能体检索 → 转换填充」,显式 False 才关闭。
extract = cfg.get("template_extract") is not False extract = cfg.get("template_extract") is not False
# 「抽取信息写回模板数据」:开启后转换成功的抽取结果除填充本次产物外,还会
# 写回模板的 data.json(默认数据持久化——管理页预览与后续任务即时沿用新数据)。
# 只写数据,MANIFEST(系统名称/导航页面等)绝不动;缺省(老任务)视为关闭。
writeback = cfg.get("template_data_writeback") is True
also_markdown = _task_requires_markdown(task) also_markdown = _task_requires_markdown(task)
prompt = str(task.get("prompt") or "").strip() prompt = str(task.get("prompt") or "").strip()
description = str(task.get("description") or "").strip() description = str(task.get("description") or "").strip()
@@ -1706,21 +1702,8 @@ class ScheduledTaskService:
page_title, page_subtitle = template_fill.pop_page_titles(converted) page_title, page_subtitle = template_fill.pop_page_titles(converted)
# 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。 # 子页面没抽到有效内容的,回退模板原有数据,避免子页面空白。
data_obj = template_fill.merge_with_reference(tpl_id, converted) data_obj = template_fill.merge_with_reference(tpl_id, converted)
# 字段管理里「接受抽取更新」被关闭(锁定)的字段,恢复模板现有值,
# 抽取结果不覆盖——对本次产物与(开启时的)数据写回同时生效。
data_obj = template_store.restore_locked_fields(tpl_id, data_obj)
content = f"已检索数据并套用模板「{spec.name}」(空子页面回退原数据,含全部子页面)" content = f"已检索数据并套用模板「{spec.name}」(空子页面回退原数据,含全部子页面)"
used_default = False used_default = False
if writeback:
try:
# 抽取结果写回模板默认数据(data.json)——仅数据,MANIFEST/系统
# 名称不动;管理页预览与后续任务即时基于更新后的默认数据。
template_store.write_template_data(tpl_id, data_obj)
content += ";抽取数据已写回模板默认数据"
except Exception:
logger.warning(
"模板数据回写失败(不影响本次产物) run=%s tpl=%s", run_id, tpl_id, exc_info=True
)
except TemplateFillError: except TemplateFillError:
logger.warning("模板参考转换失败,回退默认数据 run=%s tpl=%s", run_id, tpl_id, exc_info=True) logger.warning("模板参考转换失败,回退默认数据 run=%s tpl=%s", run_id, tpl_id, exc_info=True)
data_obj = template_fill.reference_data(tpl_id) data_obj = template_fill.reference_data(tpl_id)
@@ -14,12 +14,6 @@ HTML 模板需要的 ``DATA`` 结构后,本模块负责把数据**填充**进模
行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不 行内无换行),所以用 ``^const VAR=.*;$`` (MULTILINE) 精确命中该行整体替换,绝不
误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的 误伤模板里别处出现的同名子串。替换值用函数回调写入,避开 ``re.sub`` 把 JSON 里的
反斜杠当反向引用的坑。 反斜杠当反向引用的坑。
* **结构化模板(kind="structured")**: no.1 起引入 page-generator 范式——目录含
``template.html``(骨架,带 :data:`STRUCTURED_MARKERS` 注入标记)/``runtime.js``
(渲染逻辑 + 页面内 JSON 编辑器)/``data.json``(默认数据,兼作转换契约)/可选
``manifest.json``。填充走 :func:`compose_structured` 标记组合,产物仍是自包含
单文件 HTML;``reference_data`` 读 ``data.json``,与单文件模板的契约/校验/合并
语义完全一致。
* **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。 * **harness 层、零 app.* 依赖**:可被调度器(harness)与 Gateway 路由(app)共同 import。
公开入口:``list_templates`` / ``get_template`` / ``reference_data`` / 公开入口:``list_templates`` / ``get_template`` / ``reference_data`` /
@@ -113,14 +107,6 @@ def extract_embedded_json(text: str | None) -> Any | None:
# 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。 # 模板资源根目录(随 git 发布,与本模块同级 ``templates/`` 子目录)。
_TEMPLATES_ROOT = Path(__file__).parent / "templates" _TEMPLATES_ROOT = Path(__file__).parent / "templates"
# 结构化模板骨架里的三个注入标记(compose_structured 逐个替换)。
DATA_MARKER = "__PAGE_DATA__"
MANIFEST_MARKER = "__PAGE_MANIFEST__"
RUNTIME_MARKER = "__PAGE_RUNTIME__"
STRUCTURED_MARKERS: tuple[str, ...] = (DATA_MARKER, MANIFEST_MARKER, RUNTIME_MARKER)
# 结构化模板目录里的组成文件(manifest.json 可选,其余必备)。
STRUCTURED_PARTS: tuple[str, ...] = ("template.html", "runtime.js", "data.json", "manifest.json")
@dataclass(frozen=True) @dataclass(frozen=True)
class TemplateSpec: class TemplateSpec:
@@ -129,20 +115,9 @@ class TemplateSpec:
``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里 ``file`` 相对 :data:`_TEMPLATES_ROOT`;``data_var``/``manifest_var`` 是模板里
待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。 待替换的 JS 常量名(``manifest_var`` 为空表示该模板没有 MANIFEST 行)。
``kind`` 有两种:
* ``"single"``(默认): 单文件模板,数据以 ``const DATA={…};`` 单行内嵌,填充 =
行级替换;
* ``"structured"``: 结构化模板(page-generator 范式),目录含
``template.html``(骨架,带 :data:`STRUCTURED_MARKERS` 三个注入标记)/
``runtime.js``(渲染 + 页面内 JSON 编辑器)/``data.json``(默认数据,即转换
契约)/可选 ``manifest.json``,填充 = 标记组合,产物仍是自包含单文件 HTML。
``legacy_file`` 指向单文件时代的随包正文,供 template_store 自动升级时做
「未被手改」的字节级比对。
``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板 ``convertible=False`` 表示该模板暂只「直接展示已有数据」——执行时原样输出模板
HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入 HTML,不做 JSON 转换/校验/填充(无 ``const DATA=`` 注入行的模板用它,后续接入
转换后再改回 True)。 转换后改回 True)。
""" """
id: str id: str
@@ -152,12 +127,6 @@ class TemplateSpec:
data_var: str = "DATA" data_var: str = "DATA"
manifest_var: str = "" manifest_var: str = ""
convertible: bool = True convertible: bool = True
kind: str = "single"
legacy_file: str = ""
@property
def structured(self) -> bool:
return self.kind == "structured"
# 已注册模板(随包 seed 源 + 存储缺失时的兜底注册表)。新增模板时在此追加一条 # 已注册模板(随包 seed 源 + 存储缺失时的兜底注册表)。新增模板时在此追加一条
@@ -167,14 +136,9 @@ TEMPLATES: tuple[TemplateSpec, ...] = (
id="no1", id="no1",
name="no.1", name="no.1",
description="", description="",
file="no1/template.html", file="no1/taiwan_littoral_ui_redwhite_v2.html",
data_var="DATA", data_var="DATA",
manifest_var="MANIFEST", manifest_var="MANIFEST",
# 结构化模板(page-generator 范式):骨架 + runtime + 默认数据 + MANIFEST,
# 填充走 compose_structured 标记组合;legacy_file 是单文件时代的随包正文,
# 供 template_store 把「未被手改」的旧存储副本自动升级到结构化版。
kind="structured",
legacy_file="no1/taiwan_littoral_ui_redwhite_v2.html",
), ),
TemplateSpec( TemplateSpec(
id="no2", id="no2",
@@ -437,7 +401,6 @@ def get_template(template_id: str) -> TemplateSpec | None:
data_var="DATA", data_var="DATA",
manifest_var=str(meta.get("manifest_var") or ""), manifest_var=str(meta.get("manifest_var") or ""),
convertible=bool(meta.get("convertible")), convertible=bool(meta.get("convertible")),
kind=str(meta.get("kind") or "single"),
) )
return _TEMPLATES_BY_ID.get(template_id) return _TEMPLATES_BY_ID.get(template_id)
@@ -472,122 +435,8 @@ def _template_path(spec: TemplateSpec) -> Path:
return _TEMPLATES_ROOT / spec.file return _TEMPLATES_ROOT / spec.file
# ── 结构化模板(骨架 + runtime + 数据分文件,组合成单文件产物) ────────────────
def _structured_part(template_id: str, name: str) -> Path | None:
"""结构化模板某个组成文件的路径:存储副本优先,随包文件兜底;都没有返回 None。
``manifest.json`` 可选;其余文件缺失属打包损坏,由 :func:`_structured_text` 报错。
"""
from deerflow.runtime.scheduler import template_store
store_file = template_store.store_root() / template_id / name
if store_file.is_file():
return store_file
fallback = _TEMPLATES_ROOT / template_id / name
return fallback if fallback.is_file() else None
def _structured_text(template_id: str, name: str) -> str:
path = _structured_part(template_id, name)
if path is None:
raise TemplateError(f"结构化模板文件缺失:{template_id}/{name}")
return path.read_text(encoding="utf-8")
def _read_default_manifest(template_id: str) -> dict[str, Any]:
"""结构化模板的默认 MANIFEST;没有 manifest.json 时返回空对象。"""
path = _structured_part(template_id, "manifest.json")
if path is None:
return {}
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise TemplateError(f"解析 {template_id}/manifest.json 失败:{exc}") from exc
return obj if isinstance(obj, dict) else {}
def structured_module_labels(template_id: str) -> list[dict[str, str]]:
"""结构化模板各数据模块(DATA 顶层键)的中文显示名,管理页「编辑数据」用。
标签来源按优先级:MANIFEST.页面 的「编号」精确命中 → 「数据源/架构数据」去掉
``data/`` 前缀和 ``.json`` 后缀后命中(如 organization_charts) → 编号按 ``_``
拆分后命中任一段(如 events/projects 命中 events_projects)。都没有则回退键名。
"""
ref = reference_data(template_id)
manifest = _read_default_manifest(template_id)
labels: dict[str, str] = {}
split_index: dict[str, str] = {}
pages = manifest.get("页面")
if isinstance(pages, list):
for page in pages:
if not isinstance(page, dict):
continue
name = str(page.get("名称") or "").strip()
if not name:
continue
candidates: list[str] = []
for field in ("编号", "数据源", "架构数据"):
raw = str(page.get(field) or "").strip()
if not raw:
continue
key = raw.removeprefix("data/").removesuffix(".json")
if key:
candidates.append(key)
for key in candidates:
labels[key] = name
first = candidates[0] if candidates else ""
if first and "_" in first:
for part in first.split("_"):
if part:
split_index[part] = name
out: list[dict[str, str]] = []
for key in ref:
out.append({"key": key, "label": labels.get(key) or split_index.get(key) or key})
return out
def compose_structured(
*,
skeleton: str,
runtime: str,
data: Any,
manifest: dict[str, Any] | None = None,
) -> str:
"""把数据/运行时按标记组合进骨架,产出自包含单文件 HTML。
与 page-generator 技能的 render 脚本同一套安全转义:数据 JSON 里的 ``</`` →
``<\\/``(JSON 字符串里 ``\\/`` 合法),runtime 里的 ``</script`` → ``<\\/script``,
防止内容提前闭合 script 标签。组合后断言无残留标记。
"""
serialized_data = json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
out = skeleton.replace(DATA_MARKER, serialized_data)
if MANIFEST_MARKER in out:
serialized_manifest = (
json.dumps(manifest, ensure_ascii=False).replace("</", "<\\/")
if manifest is not None
else "{}"
)
out = out.replace(MANIFEST_MARKER, serialized_manifest)
out = out.replace(RUNTIME_MARKER, runtime.replace("</script", "<\\/script"))
residual = [m for m in STRUCTURED_MARKERS if m in out]
if residual:
raise TemplateError(f"结构化模板组合后仍残留标记:{'、'.join(residual)}")
return out
def read_template_text(template_id: str) -> str: def read_template_text(template_id: str) -> str:
spec = _require(template_id) spec = _require(template_id)
if spec.structured:
# 结构化模板对外始终呈现「组合后的完整文档」(预览/只展示/参考展示共用);
# 骨架原文(编辑用)由 template_store.read_template_html 直接读文件。
return compose_structured(
skeleton=_structured_text(template_id, "template.html"),
runtime=_structured_text(template_id, "runtime.js"),
data=reference_data(template_id),
manifest=_read_default_manifest(template_id),
)
path = _template_path(spec) path = _template_path(spec)
if not path.is_file(): if not path.is_file():
raise TemplateError(f"模板文件缺失:{path}") raise TemplateError(f"模板文件缺失:{path}")
@@ -615,22 +464,8 @@ def _extract_const_object(text: str, var: str) -> Any:
@lru_cache(maxsize=16) @lru_cache(maxsize=16)
def reference_data(template_id: str) -> dict[str, Any]: def reference_data(template_id: str) -> dict[str, Any]:
"""模板内嵌的参考 ``DATA`` 对象(目标格式范例 + 校验基准),按模板缓存。 """模板内嵌的参考 ``DATA`` 对象(目标格式范例 + 校验基准),按模板缓存。"""
单文件模板解析 ``const DATA=`` 行;结构化模板直接读 ``data.json``(两者语义一致:
顶层键即子页面,validate_data/merge_with_reference/骨架 prompt 全部通用)。
"""
spec = _require(template_id) spec = _require(template_id)
if spec.structured:
try:
obj = json.loads(_structured_text(template_id, "data.json"))
except OSError as exc:
raise TemplateError(f"读取 {template_id}/data.json 失败:{exc}") from exc
except ValueError as exc:
raise TemplateError(f"解析 {template_id}/data.json 失败:{exc}") from exc
if not isinstance(obj, dict):
raise TemplateError(f"模板 {template_id} 的 data.json 不是对象")
return obj
text = read_template_text(template_id) text = read_template_text(template_id)
obj = _extract_const_object(text, spec.data_var) obj = _extract_const_object(text, spec.data_var)
if not isinstance(obj, dict): if not isinstance(obj, dict):
@@ -939,18 +774,28 @@ def pop_page_titles(obj: dict[str, Any]) -> tuple[str | None, str | None]:
return title, subtitle return title, subtitle
def _apply_page_titles( def fill_template(
text: str, template_id: str,
title: str | None, data_obj: Any,
subtitle: str | None, *,
manifest_overrides: dict[str, Any] | None, manifest_overrides: dict[str, Any] | None = None,
) -> tuple[str, dict[str, Any] | None]: update_date: date | None = None,
"""替换页头静态标题/副标题;给出 title 时同步返回含 ``系统.名称`` 的 overrides。 title: str | None = None,
subtitle: str | None = None,
) -> str:
"""把 ``data_obj`` 填进模板的 ``const DATA=…`` 行,产出完整 HTML 文档。
模板页头(``<title>``、第一个 ``<h1>``、其后紧跟的副标题 ``<p>``)是写死的静态 可选地更新 MANIFEST 的「系统.更新时间」(``update_date``,默认不动) 以及任意
HTML,不随 DATA 变化——给出新标题时替换这两处,并把 MANIFEST 的 系统.名称 一并 ``manifest_overrides``(深合并到解析后的 MANIFEST,如改系统名称)。
同步;不给则保持模板原文。单文件/结构化两条填充路径共用。
``title``/``subtitle`` 来自转换结果里的「页面标题/页面副标题」:模板页头
(<title>、第一个 <h1>、其后紧跟的副标题 <p>)是写死的静态 HTML,不随 DATA
变化——给出新标题时替换这三处,并把 MANIFEST 的 系统.名称 一并同步;
不给则保持模板原文。
""" """
spec = _require(template_id)
text = read_template_text(template_id)
if title: if title:
overrides = dict(manifest_overrides or {}) overrides = dict(manifest_overrides or {})
system = overrides.get("系统") system = overrides.get("系统")
@@ -970,70 +815,6 @@ def _apply_page_titles(
count=1, count=1,
flags=re.DOTALL, flags=re.DOTALL,
) )
return text, manifest_overrides
def _fill_structured(
template_id: str,
data_obj: Any,
*,
manifest_overrides: dict[str, Any] | None,
update_date: date | None,
title: str | None,
subtitle: str | None,
) -> str:
"""结构化模板填充:标记组合出完整单文件 HTML(数据内嵌 JSON 元素 + runtime 内联)。"""
skeleton, manifest_overrides = _apply_page_titles(
_structured_text(template_id, "template.html"), title, subtitle, manifest_overrides
)
manifest = _read_default_manifest(template_id)
if update_date is not None:
system = manifest.get("系统")
if isinstance(system, dict):
system["更新时间"] = update_date.isoformat()
if manifest_overrides:
_deep_merge(manifest, manifest_overrides)
return compose_structured(
skeleton=skeleton,
runtime=_structured_text(template_id, "runtime.js"),
data=data_obj,
manifest=manifest,
)
def fill_template(
template_id: str,
data_obj: Any,
*,
manifest_overrides: dict[str, Any] | None = None,
update_date: date | None = None,
title: str | None = None,
subtitle: str | None = None,
) -> str:
"""把 ``data_obj`` 填进模板,产出完整 HTML 文档。
单文件模板:行级替换 ``const DATA=…``;结构化模板::func:`_fill_structured`
标记组合。可选地更新 MANIFEST 的「系统.更新时间」(``update_date``,默认不动)
以及任意 ``manifest_overrides``(深合并到解析后的 MANIFEST,如改系统名称)。
``title``/``subtitle`` 来自转换结果里的「页面标题/页面副标题」:模板页头
(<title>、第一个 <h1>、其后紧跟的副标题 <p>)是写死的静态 HTML,不随 DATA
变化——给出新标题时替换这三处,并把 MANIFEST 的 系统.名称 一并同步;
不给则保持模板原文。
"""
spec = _require(template_id)
if spec.structured:
return _fill_structured(
template_id,
data_obj,
manifest_overrides=manifest_overrides,
update_date=update_date,
title=title,
subtitle=subtitle,
)
text = read_template_text(template_id)
text, manifest_overrides = _apply_page_titles(text, title, subtitle, manifest_overrides)
data_re = _const_line_re(spec.data_var) data_re = _const_line_re(spec.data_var)
if data_re.search(text) is None: if data_re.search(text) is None:
@@ -5,27 +5,17 @@
升级为**文件型存储**,模板管理页与定时任务表单共用: 升级为**文件型存储**,模板管理页与定时任务表单共用:
{base_dir}/html-templates/<id>/ {base_dir}/html-templates/<id>/
├── template.html # 模板正文(单文件模板 = 完整文档;结构化模板 = 骨架) ├── template.html # 模板正文
├── runtime.js # 结构化模板:渲染逻辑 + 页面内 JSON 编辑器(可选件) └── meta.json # {id, name, description, builtin, convertible, manifest_var, created_at, updated_at}
├── data.json # 结构化模板:默认数据,兼作转换契约(可选件)
├── manifest.json # 结构化模板:系统/页面/交互规则(可选件)
└── meta.json # {id, name, description, builtin, kind, convertible, manifest_var, created_at, updated_at}
设计要点 设计要点
-------- --------
* **seed 不覆盖**:{base_dir}/html-templates/<id>/ 目录缺失时才从随包模板复制(与 * **seed 不覆盖**:{base_dir}/html-templates/<id>/ 目录缺失时才从随包模板复制(与
``_template_builder_seed.py`` 同语义),线上/管理页的修改绝不会被启动 seed 回写。 ``_template_builder_seed.py`` 同语义),线上/管理页的修改绝不会被启动 seed 回写。
**例外——结构化自动升级**:注册表条目升级为 ``kind="structured"`` 后,若存储里的 * **convertible 自动探测**:写入(创建/改正文/重置)时扫描正文是否含可解析的内联
旧单文件副本与随包 ``legacy_file`` 字节一致(即从未被手改),启动时自动重建为 ``const DATA={…};`` 行(当前填充引擎唯一支持的注入点)——有则该模板可「JSON 转换
结构化版;改过的保留旧版,由管理页「重置」手动升级。 填充」,无则「只展示」。上传带注入行的新模板即可零代码接入转换管线;探测同时认
* **convertible 自动探测**(单文件模板):写入(创建/改正文/重置)时扫描正文是否含 ``const MANIFEST=`` 行并记入 meta,``fill_template`` 的更新时间/标题替换可生效。
可解析的内联 ``const DATA={…};`` 行——有则该模板可「JSON 转换填充」,无则「只展示」。
结构化模板的 convertible/manifest_var 以注册表/``data.json`` 为准,不靠正文探测。
* **结构化模板的数据持久化**:``read_template_data`` / ``write_template_data`` 支撑
管理页「编辑数据」——整体替换 ``data.json``,替换前经
:func:`template_fill.validate_data` 契约校验(顶层键不可增删),改契约先改模板。
编辑结构化正文仅允许改骨架,且必须保留 :data:`template_fill.STRUCTURED_MARKERS`
三个注入标记。
* **内置模板可改不可删**:删除会把引用它的定时任务全部弄挂,内置模板(builtin) * **内置模板可改不可删**:删除会把引用它的定时任务全部弄挂,内置模板(builtin)
只允许编辑/上传覆盖/重置回随包原版,自建模板可删。 只允许编辑/上传覆盖/重置回随包原版,自建模板可删。
* harness 层、零 app.* 依赖:调度器与 Gateway 路由共同 import;所有写操作都会 * harness 层、零 app.* 依赖:调度器与 Gateway 路由共同 import;所有写操作都会
@@ -124,84 +114,42 @@ def _validate_html(html: str) -> str:
def ensure_seeded() -> bool: def ensure_seeded() -> bool:
"""把随包内置模板 seed 进存储;目录已存在(尊重管理页修改)则跳过。 """把随包内置模板 seed 进存储;目录已存在(尊重管理页修改)则跳过。
结构化模板多一步**自动升级**:注册表条目已是 ``kind="structured"`` 而存储 meta 返回 True 表示本次至少复制了一个模板。任何单模板失败只影响该模板,不抛出。
还没有 kind(单文件时代的旧副本)时,若旧副本正文与随包 ``legacy_file`` 字节一致
(从未手改)则重建为结构化版;改过的保留,由管理页「重置」手动升级。
返回 True 表示本次至少新建/升级了一个模板。任何单模板失败只影响该模板,不抛出。
""" """
changed = False created = False
for spec in template_fill.TEMPLATES: for spec in template_fill.TEMPLATES:
try: try:
tpl_dir = store_root() / spec.id tpl_dir = store_root() / spec.id
if tpl_dir.exists(): if tpl_dir.exists():
if spec.structured:
changed |= _maybe_upgrade_structured(spec)
continue continue
_seed_spec_dir(spec, tpl_dir)
changed = True
except Exception:
continue
if changed:
template_fill.invalidate_cache()
return changed
def _seed_spec_dir(spec: Any, tpl_dir: Path) -> None:
"""按注册表条目把一个内置模板 seed 进存储目录(调用方保证目录不存在)。"""
tpl_dir.mkdir(parents=True, exist_ok=True) tpl_dir.mkdir(parents=True, exist_ok=True)
src_root = template_fill._TEMPLATES_ROOT / spec.id
if spec.structured:
# 必备件缺失属打包损坏:抛错由 ensure_seeded 按单模板吞掉。
for name in ("template.html", "runtime.js", "data.json"):
(tpl_dir / name).write_text(
(src_root / name).read_text(encoding="utf-8"), encoding="utf-8"
)
for name in ("manifest.json", "fields.json"):
optional = src_root / name
if optional.is_file():
(tpl_dir / name).write_text(
optional.read_text(encoding="utf-8"), encoding="utf-8"
)
else:
src = template_fill._TEMPLATES_ROOT / spec.file src = template_fill._TEMPLATES_ROOT / spec.file
(tpl_dir / "template.html").write_text( html = src.read_text(encoding="utf-8")
src.read_text(encoding="utf-8"), encoding="utf-8" (tpl_dir / "template.html").write_text(html, encoding="utf-8")
) now = _now_iso()
_write_meta( (tpl_dir / "meta.json").write_text(
spec.id, json.dumps(
{ {
"id": spec.id, "id": spec.id,
"name": spec.name, "name": spec.name,
"description": spec.description, "description": spec.description,
"builtin": True, "builtin": True,
"kind": spec.kind,
"convertible": spec.convertible, "convertible": spec.convertible,
"manifest_var": spec.manifest_var, "manifest_var": spec.manifest_var,
"created_at": now,
"updated_at": now,
}, },
keep_created=False, ensure_ascii=False,
indent=1,
),
encoding="utf-8",
) )
created = True
except Exception:
def _maybe_upgrade_structured(spec: Any) -> bool: continue
"""旧单文件存储副本 → 结构化版(仅当正文与随包 legacy 文件字节一致,即从未手改)。""" if created:
tpl_dir = store_root() / spec.id template_fill.invalidate_cache()
if not tpl_dir.is_dir() or not spec.legacy_file: return created
return False
if _load_meta(tpl_dir).get("kind"):
return False # 已是结构化
legacy = template_fill._TEMPLATES_ROOT / spec.legacy_file
current = tpl_dir / "template.html"
if not legacy.is_file() or not current.is_file():
return False
try:
if legacy.read_bytes() != current.read_bytes():
return False # 线上手改过 → 保留旧版,「重置」手动升级
except OSError:
return False
shutil.rmtree(tpl_dir)
_seed_spec_dir(spec, tpl_dir)
return True
# ── 读取 ───────────────────────────────────────────────────────────────────── # ── 读取 ─────────────────────────────────────────────────────────────────────
@@ -221,9 +169,8 @@ def _load_meta(tpl_dir: Path) -> dict[str, Any]:
def list_templates() -> list[dict[str, Any]]: def list_templates() -> list[dict[str, Any]]:
"""存储内全部模板(内置按注册表顺序在前,自建按创建时间在后)。 """存储内全部模板(内置按注册表顺序在前,自建按创建时间在后)。
返回条目:id/name/description/builtin/kind/convertible/manifest_var/size/created_at/ 返回条目:id/name/description/builtin/convertible/manifest_var/size/created_at/
updated_at;正文缺失的目录跳过(半成品不进下拉)。结构化模板的 size 汇总 updated_at;正文缺失的目录跳过(半成品不进下拉)。
template.html+runtime.js+data.json+manifest.json。
""" """
ensure_seeded() ensure_seeded()
root = store_root() root = store_root()
@@ -237,24 +184,15 @@ def list_templates() -> list[dict[str, Any]]:
if not html_file.is_file(): if not html_file.is_file():
continue continue
meta = _load_meta(tpl_dir) meta = _load_meta(tpl_dir)
kind = str(meta.get("kind") or "single")
size = html_file.stat().st_size
if kind == "structured":
size += sum(
(tpl_dir / name).stat().st_size
for name in ("runtime.js", "data.json", "manifest.json")
if (tpl_dir / name).is_file()
)
entries.append( entries.append(
{ {
"id": tpl_dir.name, "id": tpl_dir.name,
"name": str(meta.get("name") or tpl_dir.name), "name": str(meta.get("name") or tpl_dir.name),
"description": meta.get("description") or "", "description": meta.get("description") or "",
"builtin": bool(meta.get("builtin")), "builtin": bool(meta.get("builtin")),
"kind": kind,
"convertible": bool(meta.get("convertible")), "convertible": bool(meta.get("convertible")),
"manifest_var": str(meta.get("manifest_var") or ""), "manifest_var": str(meta.get("manifest_var") or ""),
"size": size, "size": html_file.stat().st_size,
"created_at": meta.get("created_at"), "created_at": meta.get("created_at"),
"updated_at": meta.get("updated_at"), "updated_at": meta.get("updated_at"),
} }
@@ -277,286 +215,12 @@ def get_template_meta(template_id: str) -> dict[str, Any] | None:
def read_template_html(template_id: str) -> str: def read_template_html(template_id: str) -> str:
"""模板正文**原文**(结构化模板 = 带 __PAGE_* 标记的骨架,非完整可预览文档)。
需要结构化模板的完整文档(预览/只展示)请用
:func:`template_fill.read_template_text`,它会把默认数据与 runtime 组合进去。
"""
path = store_template_file(template_id) path = store_template_file(template_id)
if not path.is_file(): if not path.is_file():
raise TemplateStoreError(f"模板不存在:{template_id}") raise TemplateStoreError(f"模板不存在:{template_id}")
return path.read_text(encoding="utf-8") return path.read_text(encoding="utf-8")
# ── 结构化模板数据(data.json)的读写(管理页「编辑数据」持久化) ────────────────
def _require_structured(template_id: str) -> dict[str, Any]:
entry = get_template_meta(template_id)
if entry is None:
raise TemplateStoreError(f"模板不存在:{template_id}")
if str(entry.get("kind") or "single") != "structured":
raise TemplateStoreError(f"模板不是结构化模板,不支持编辑数据:{template_id}")
return entry
def read_template_data(template_id: str) -> dict[str, Any]:
"""结构化模板的默认数据(data.json);存储缺文件时按引擎同一回退读随包。"""
_require_structured(template_id)
path = store_root() / template_id / "data.json"
if not path.is_file():
return template_fill.reference_data(template_id)
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise TemplateStoreError(f"读取 data.json 失败:{exc}") from exc
if not isinstance(obj, dict):
raise TemplateStoreError("data.json 不是 JSON 对象")
return obj
def read_template_manifest(template_id: str) -> dict[str, Any]:
"""结构化模板的 MANIFEST(manifest.json,系统名称/导航页面/交互规则);缺失返回空对象。"""
_require_structured(template_id)
path = store_root() / template_id / "manifest.json"
if not path.is_file():
return {}
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise TemplateStoreError(f"读取 manifest.json 失败:{exc}") from exc
if not isinstance(obj, dict):
raise TemplateStoreError("manifest.json 不是 JSON 对象")
return obj
def _dump_json_checked(obj: Any, label: str) -> str:
payload = json.dumps(obj, ensure_ascii=False)
encoded = payload.encode("utf-8")
if len(encoded) > MAX_HTML_BYTES:
raise TemplateStoreError(
f"{label}过大({len(encoded)} 字节,上限 {MAX_HTML_BYTES} 字节)"
)
return payload
def write_template_data(
template_id: str,
data_obj: Any,
*,
manifest: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""整体替换结构化模板的默认数据(可选一并替换 MANIFEST)并清缓存;返回更新后的 meta。
data.json 替换前经 :func:`template_fill.validate_data` 契约校验(顶层键不可增删);
``manifest`` 给出时整体替换 manifest.json(系统名称/导航页面/交互规则的持久化入口)。
"""
_require_structured(template_id)
if not isinstance(data_obj, dict):
raise TemplateStoreError("数据必须是 JSON 对象")
errors = template_fill.validate_data(template_id, data_obj)
if errors:
raise TemplateStoreError(
"数据未通过模板契约校验(顶层键须与现有契约一致):" + ";".join(errors)
)
payload = _dump_json_checked(data_obj, "数据")
(store_root() / template_id / "data.json").write_text(payload, encoding="utf-8")
if manifest is not None:
if not isinstance(manifest, dict):
raise TemplateStoreError("MANIFEST 必须是 JSON 对象")
(store_root() / template_id / "manifest.json").write_text(
_dump_json_checked(manifest, "MANIFEST"), encoding="utf-8"
)
_write_meta(template_id, _load_meta(store_root() / template_id), keep_created=True)
template_fill.invalidate_cache()
return get_template_meta(template_id) # type: ignore[return-value]
# ── 字段显示 / 自定义模块(manifest 增量配置,字段目录读 fields.json) ─────────
def read_template_fields(template_id: str) -> list[dict[str, Any]]:
"""结构化模板的字段目录(fields.json:label/pages/description);没有目录返回空表。"""
_require_structured(template_id)
for base in (store_root() / template_id, template_fill._TEMPLATES_ROOT / template_id):
path = base / "fields.json"
if path.is_file():
try:
obj = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise TemplateStoreError(f"读取 fields.json 失败:{exc}") from exc
fields = obj.get("fields") if isinstance(obj, dict) else None
if isinstance(fields, list):
return [f for f in fields if isinstance(f, dict) and f.get("label")]
return []
return []
def _write_manifest_section(
template_id: str, key: str, value: Any, *, validate=None
) -> dict[str, Any]:
"""更新 manifest.json 的单个顶层键(其余原样保留)并清引擎缓存;返回更新后的 meta。
``validate(value)`` 可选,抛 TemplateStoreError 时拒绝写入。
"""
_require_structured(template_id)
manifest = read_template_manifest(template_id)
if validate is not None:
validate(value)
manifest[key] = value
(store_root() / template_id / "manifest.json").write_text(
_dump_json_checked(manifest, "MANIFEST"), encoding="utf-8"
)
_write_meta(template_id, _load_meta(store_root() / template_id), keep_created=True)
template_fill.invalidate_cache()
return get_template_meta(template_id) # type: ignore[return-value]
def write_template_field_visibility(
template_id: str, hidden_labels: list[str]
) -> dict[str, Any]:
"""字段显示/隐藏配置:manifest 的「字段显示」记 {label: false}(未记录=显示)。
标签须来自字段目录(fields.json),防止把「系统设置」里的同名键写坏。
"""
catalog = {f["label"] for f in read_template_fields(template_id)}
unknown = [x for x in hidden_labels if x not in catalog]
if unknown:
raise TemplateStoreError("未知字段:" + "、".join(unknown[:5]))
hidden = {label: False for label in hidden_labels}
return _write_manifest_section(template_id, "字段显示", hidden)
def write_template_field_locks(template_id: str, locked_labels: list[str]) -> dict[str, Any]:
"""字段「接受抽取更新」锁定名单:manifest「字段抽取关闭」记 {label: True}。
锁定后,定时任务抽取/写回不改该字段对应的数据键(保持模板现有数据);
名单为空 = 全部接受抽取更新(默认)。
"""
catalog = {f["label"] for f in read_template_fields(template_id)}
unknown = [x for x in locked_labels if x not in catalog]
if unknown:
raise TemplateStoreError("未知字段:" + "、".join(unknown[:5]))
locks = {label: True for label in locked_labels}
return _write_manifest_section(template_id, "字段抽取关闭", locks)
def locked_field_keys(template_id: str) -> set[str]:
"""被锁定(不接受抽取更新)字段对应的数据键集合;无锁定时为空集。"""
manifest = read_template_manifest(template_id)
locks = manifest.get("字段抽取关闭")
locks = locks if isinstance(locks, dict) else {}
if not any(v is True for v in locks.values()):
return set()
keys: set[str] = set()
for f in read_template_fields(template_id):
if locks.get(f["label"]) is True:
keys.update(str(k) for k in (f.get("keys") or []) if k)
return keys
def _item_identity(item: Any) -> str | None:
"""列表条目的身份键(id/编号/name/名称 取第一个非空),锁定恢复时按它匹配新旧条目。"""
if not isinstance(item, dict):
return None
for key in ("id", "编号", "name", "名称"):
value = item.get(key)
if isinstance(value, str) and value.strip():
return f"{key}:{value.strip()}"
if isinstance(value, (int, float)) and not isinstance(value, bool):
return f"{key}:{value}"
return None
def restore_locked_fields(template_id: str, data_obj: dict[str, Any]) -> dict[str, Any]:
"""把锁定字段的数据键恢复为模板现有值——抽取结果不覆盖,供调度器在
``merge_with_reference`` 之后、填充/写回之前调用。
列表分支按条目身份匹配新旧条目,匹配上才恢复(新条目保持抽取值);
对象分支(overview)逐键恢复。无锁定时原样返回。
"""
keys = locked_field_keys(template_id)
if not keys:
return data_obj
reference = template_fill.reference_data(template_id)
out = dict(data_obj)
for branch, new_val in data_obj.items():
old_val = reference.get(branch)
if isinstance(old_val, list) and isinstance(new_val, list):
old_index: dict[str, dict[str, Any]] = {}
for old_item in old_val:
if isinstance(old_item, dict):
ident = _item_identity(old_item)
if ident:
old_index[ident] = old_item
restored: list[Any] = []
for new_item in new_val:
if isinstance(new_item, dict):
ident = _item_identity(new_item)
old_item = old_index.get(ident) if ident else None
if old_item is not None:
merged = dict(new_item)
for key in keys:
if key in old_item:
merged[key] = old_item[key]
restored.append(merged)
continue
restored.append(new_item)
out[branch] = restored
elif isinstance(old_val, dict) and isinstance(new_val, dict):
merged = dict(new_val)
for key in keys:
if key in old_val:
merged[key] = old_val[key]
out[branch] = merged
return out
def write_template_custom_modules(
template_id: str, modules: list[dict[str, Any]]
) -> dict[str, Any]:
"""自定义模块列表(挂在指定页面底部的 AI 生成 HTML 片段)整体替换。
每项 {编号, 页面, 标题, html};html 经 :func:`sanitize_html_fragment` 消毒
(去 script/事件属性/javascript: 链接)后才允许入库。
"""
def _validate(value: Any) -> None:
if not isinstance(value, list):
raise TemplateStoreError("自定义模块必须是数组")
for i, item in enumerate(value):
if not isinstance(item, dict):
raise TemplateStoreError(f"自定义模块[{i}]必须是对象")
page = str(item.get("页面") or "").strip()
if not page:
raise TemplateStoreError(f"自定义模块[{i}]缺少「页面」")
html = str(item.get("html") or "").strip()
if not html:
raise TemplateStoreError(f"自定义模块[{i}]缺少内容 html")
item["html"] = sanitize_html_fragment(html)
return _write_manifest_section(template_id, "自定义模块", modules, validate=_validate)
# 自定义模块 HTML 片段消毒:去 <script>/<style 事件>/on* 事件属性/javascript: 链接。
_SCRIPT_TAG_RE = re.compile(r"<\s*/?\s*script\b[^>]*>", re.IGNORECASE)
_SCRIPT_BLOCK_RE = re.compile(r"<\s*script\b.*?<\s*/\s*script\s*>", re.IGNORECASE | re.DOTALL)
_ON_ATTR_RE = re.compile(r"\son[a-zA-Z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", re.IGNORECASE)
_JS_URL_RE = re.compile(
r"(href|src|action)\s*=\s*(\"[^\"]*javascript:[^\"]*\"|'[^']*javascript:[^']*'|javascript:[^\s>]*)",
re.IGNORECASE,
)
def sanitize_html_fragment(html: str) -> str:
"""自定义模块片段入库前的安全清洗(页面经 innerHTML 渲染,须无脚本可执行)。"""
out = _SCRIPT_BLOCK_RE.sub("", str(html or ""))
out = _SCRIPT_TAG_RE.sub("", out)
out = _ON_ATTR_RE.sub("", out)
out = _JS_URL_RE.sub("", out)
return out.strip()
# ── 写入 ───────────────────────────────────────────────────────────────────── # ── 写入 ─────────────────────────────────────────────────────────────────────
@@ -611,27 +275,13 @@ def update_template(
description: str | None = None, description: str | None = None,
html: str | None = None, html: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""编辑模板(名称/描述/正文任选;改正文时重新探测 convertible 并清引擎缓存)。 """编辑模板(名称/描述/正文任选;改正文时重新探测 convertible 并清引擎缓存)。"""
结构化模板的正文编辑仅针对**骨架**,保存前校验三个注入标记齐全(删标记 = 断了
组合管线);其 convertible/manifest_var 不靠正文探测,保持原值。改默认数据请用
:func:`write_template_data`。
"""
entry = get_template_meta(template_id) entry = get_template_meta(template_id)
if entry is None: if entry is None:
raise TemplateStoreError(f"模板不存在:{template_id}") raise TemplateStoreError(f"模板不存在:{template_id}")
structured = str(entry.get("kind") or "single") == "structured"
if html is not None: if html is not None:
html = _validate_html(html) html = _validate_html(html)
if structured:
missing = [m for m in template_fill.STRUCTURED_MARKERS if m not in html]
if missing:
raise TemplateStoreError(
"结构化模板骨架缺少注入标记:"
+ "、".join(missing)
+ "(编辑正文不可删除标记;改数据请用「编辑数据」)"
)
store_template_file(template_id).write_text(html, encoding="utf-8") store_template_file(template_id).write_text(html, encoding="utf-8")
meta = _load_meta(store_root() / template_id) meta = _load_meta(store_root() / template_id)
@@ -642,7 +292,7 @@ def update_template(
meta["name"] = name meta["name"] = name
if description is not None: if description is not None:
meta["description"] = description.strip() meta["description"] = description.strip()
if html is not None and not structured: if html is not None:
convertible, manifest_var = _detect_specs(html) convertible, manifest_var = _detect_specs(html)
meta["convertible"] = convertible meta["convertible"] = convertible
meta["manifest_var"] = manifest_var meta["manifest_var"] = manifest_var
@@ -663,31 +313,14 @@ def delete_template(template_id: str) -> None:
def reset_template(template_id: str) -> dict[str, Any]: def reset_template(template_id: str) -> dict[str, Any]:
"""把内置模板重置回随包原版(meta 名称/描述保留,convertible 重新按注册表)。 """把内置模板的正文重置回随包原版(meta 名称/描述保留,convertible 重新探测)。"""
结构化模板重置全部组成文件(template.html/runtime.js/data.json/manifest.json)。
"""
seed = next((s for s in template_fill.TEMPLATES if s.id == template_id), None) seed = next((s for s in template_fill.TEMPLATES if s.id == template_id), None)
entry = get_template_meta(template_id) entry = get_template_meta(template_id)
if seed is None or entry is None: if seed is None or entry is None:
raise TemplateStoreError(f"仅内置模板支持重置:{template_id}") raise TemplateStoreError(f"仅内置模板支持重置:{template_id}")
if seed.structured:
src_root = template_fill._TEMPLATES_ROOT / template_id
for name in ("template.html", "runtime.js", "data.json"):
(store_root() / template_id / name).write_text(
(src_root / name).read_text(encoding="utf-8"), encoding="utf-8"
)
for name in ("manifest.json", "fields.json"):
optional = src_root / name
if optional.is_file():
(store_root() / template_id / name).write_text(
optional.read_text(encoding="utf-8"), encoding="utf-8"
)
else:
html = (template_fill._TEMPLATES_ROOT / seed.file).read_text(encoding="utf-8") html = (template_fill._TEMPLATES_ROOT / seed.file).read_text(encoding="utf-8")
store_template_file(template_id).write_text(html, encoding="utf-8") store_template_file(template_id).write_text(html, encoding="utf-8")
meta = _load_meta(store_root() / template_id) meta = _load_meta(store_root() / template_id)
meta["kind"] = seed.kind
meta["convertible"] = seed.convertible meta["convertible"] = seed.convertible
meta["manifest_var"] = seed.manifest_var meta["manifest_var"] = seed.manifest_var
_write_meta(template_id, meta, keep_created=True) _write_meta(template_id, meta, keep_created=True)
@@ -1 +0,0 @@
{"overview": {"核心指标": [{"名称": "综合区域", "数值": 9, "单位": "区", "说明": "覆盖本岛沿岸与主要离岛"}, {"名称": "组织与单位", "数值": 280, "单位": "个", "说明": "已去除组织与单位重复"}, {"名称": "单位深度档案", "数值": 179, "单位": "份", "说明": "已合并到对应组织节点"}, {"名称": "装备条目", "数值": 99, "单位": "项", "说明": "区分规划、采购、测试和列装"}, {"名称": "关键节点", "数值": 141, "单位": "个", "说明": "公共功能与韧性视角"}, {"名称": "演习与项目", "数值": 12, "单位": "项", "说明": "事件时间线与建设项目"}], "组织体系分布": [{"名称": "组织治理体系", "数值": 101}, {"名称": "海巡", "数值": 43}, {"名称": "全民动员", "数值": 29}, {"名称": "国防与联合体系", "数值": 28}, {"名称": "海军", "数值": 21}, {"名称": "陆军", "数值": 18}, {"名称": "空军", "数值": 16}, {"名称": "公共支援", "数值": 16}, {"名称": "海军陆战队", "数值": 8}], "装备类别分布": [{"名称": "无人系统", "数值": 12}, {"名称": "防空", "数值": 11}, {"名称": "海军平台", "数值": 10}, {"名称": "海巡平台", "数值": 9}, {"名称": "航空", "数值": 8}, {"名称": "地面火力", "数值": 7}, {"名称": "装甲", "数值": 5}, {"名称": "雷达与感知", "数值": 5}, {"名称": "反装甲", "数值": 4}, {"名称": "反舰", "数值": 3}, {"名称": "潜舰", "数值": 3}, {"名称": "反水雷", "数值": 3}, {"名称": "舰载防空", "数值": 3}, {"名称": "电子战", "数值": 3}, {"名称": "指挥控制", "数值": 3}, {"名称": "工程保障", "数值": 2}, {"名称": "布雷", "数值": 1}, {"名称": "综合支援", "数值": 1}, {"名称": "反无人机", "数值": 1}, {"名称": "无人水面平台", "数值": 1}, {"名称": "通信", "数值": 1}, {"名称": "机动运输", "数值": 1}, {"名称": "防护", "数值": 1}, {"名称": "医疗保障", "数值": 1}], "节点类别分布": [{"名称": "港口与海运", "数值": 26}, {"名称": "供水", "数值": 22}, {"名称": "能源与燃料", "数值": 18}, {"名称": "机场与航空", "数值": 17}, {"名称": "产业、物流与维修", "数值": 15}, {"名称": "陆路交通", "数值": 13}, {"名称": "医疗与救援", "数值": 11}, {"名称": "通信与数位韧性", "数值": 8}, {"名称": "应变指挥与警报", "数值": 6}, {"名称": "离岛持续保障", "数值": 5}], "业务入口": [{"页面": "regions", "名称": "沿岸区域", "数量": 9, "说明": "九区、县市与海岸分段"}, {"页面": "org_units", "名称": "组织与单位", "数量": 280, "说明": "组织架构图与单位深度档案"}, {"页面": "equipment", "名称": "武器装备", "数量": 99, "说明": "类别、体系、状态和公开作用"}, {"页面": "key_nodes", "名称": "关键节点", "数量": 141, "说明": "公共功能、依赖与替代"}, {"页面": "events_projects", "名称": "演习与项目", "数量": 12, "说明": "事件时间线和建设项目"}], "数据状态": {"知识库版本": "2.0", "页面版本": "2.0", "更新时间": "2026-06-23", "校验状态": "通过"}}, "regions": [{"region_id": "R01", "name": "北部沿岸", "scope": "基隆市、新北市沿岸及台北都会支撑区", "coastal_counties": ["基隆市", "新北市"], "support_areas": ["台北市", "桃园北部"], "coastal_morphology": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "segment_ids": ["S-R01-01", "S-R01-02", "S-R01-03", "S-R01-04", "S-R01-05"], "source_ids": ["SRC-WRA-TAIPEI-COAST", "SRC-TIPC-PORTS-2026", "SRC-CGA-ORG-2024", "SRC-CAA-AIRPORTS-2024"], "last_verified": "2026-06-23", "safety": "aggregate_only", "编号": "R01", "名称": "北部沿岸", "沿海县市": ["基隆市", "新北市"], "支撑区域": ["台北市", "桃园北部"], "海岸形态": ["野柳—东北角岬湾岩岸", "基隆港湾与人工港岸", "淡水河口沙岸与河口湿地", "八里—林口台地前缘海岸"], "县市子库": [{"county_id": "C-KEELUNG", "name": "基隆市", "region_id": "R01", "coastal_belt": ["基隆港城、外木山及东北角接口"], "dominant_functions": ["港口城市", "国际商港", "都会支撑"], "priority_risks": ["港城交通耦合", "强浪台风", "危险品与港区事故"], "last_verified": "2026-06-23", "safety": "aggregate_only"}, {"county_id": "C-NEWTAIPEI", "name": "新北市", "region_id": "R01", "coastal_belt": ["八里、林口、淡水、三芝、石门、金山、万里、瑞芳、贡寮"], "dominant_functions": ["台北港", "淡水河口", "北海岸观光渔业"], "priority_risks": ["海岸类型差异大", "跨都会交通", "东北角山区通道"], "last_verified": "2026-06-23", "safety": "aggregate_only"}], "海岸分段": [{"segment_id": "S-R01-01", "region_id": "R01", "name": "基隆港城段", Line truncated
@@ -1,557 +0,0 @@
{
"说明": "字段显示目录:控制页面详情字段/分区的显示与隐藏 + 是否接受定时任务抽取数据更新(keys=该字段对应的数据键;无 keys 为布局分区)。可见性存于 manifest「字段显示」,抽取锁定存于「字段抽取关闭」。",
"fields": [
{
"label": "上游依赖",
"pages": [
"关键节点"
],
"description": "该节点正常运行所依赖的上游设施",
"keys": [
"依赖类别"
]
},
{
"label": "主管或运营主体",
"pages": [
"关键节点"
],
"description": "节点的主管部门或运营单位",
"keys": [
"主管运营"
]
},
{
"label": "体系",
"pages": [
"组织与单位"
],
"description": "单位所属的组织治理体系分类",
"keys": [
"体系"
]
},
{
"label": "使用体系",
"pages": [
"武器装备"
],
"description": "装备配属使用的组织体系",
"keys": [
"使用体系中文"
]
},
{
"label": "公共功能",
"pages": [
"关键节点"
],
"description": "节点承担的公共功能",
"keys": [
"公共功能"
]
},
{
"label": "公开作用",
"pages": [
"武器装备"
],
"description": "装备公开承认的作用与用途",
"keys": [
"公开作用"
]
},
{
"label": "公开状态",
"pages": [
"武器装备"
],
"description": "装备的公开状态(在役、测试、规划等)",
"keys": [
"状态中文"
]
},
{
"label": "公开职掌与任务",
"pages": [
"组织与单位"
],
"description": "单位公开的职责与承担的任务",
"keys": [
"公开任务"
]
},
{
"label": "军令或任务上级",
"pages": [
"组织与单位"
],
"description": "其军令或任务线条上的上级单位",
"keys": [
"军令或任务上级"
]
},
{
"label": "分析限制",
"pages": [
"演习与项目"
],
"description": "对单次演习/项目的分析局限说明",
"keys": []
},
{
"label": "别名",
"pages": [
"武器装备"
],
"description": "装备的其它常用名称或编号",
"keys": [
"aliases"
]
},
{
"label": "区域",
"pages": [
"演习与项目"
],
"description": "与该条目相关的地理区域",
"keys": [
"区域中文"
]
},
{
"label": "区域、装备与演训关联",
"pages": [
"组织与单位"
],
"description": "与该单位相关的区域、装备及演习事件",
"keys": []
},
{
"label": "区域关联",
"pages": [
"组织与单位"
],
"description": "单位关联的沿岸区域",
"keys": [
"区域关联"
]
},
{
"label": "区域基本信息",
"pages": [
"沿岸区域"
],
"description": "区域的名称、编号与总体概况",
"keys": []
},
{
"label": "历史沿革与说明",
"pages": [
"组织与单位"
],
"description": "单位的历史变迁与补充说明",
"keys": [
"历史沿革"
]
},
{
"label": "县市子库",
"pages": [
"沿岸区域"
],
"description": "该区域包含的沿海县市清单",
"keys": [
"coastal_counties"
]
},
{
"label": "县市数量",
"pages": [
"沿岸区域"
],
"description": "该区域下辖沿海县市的具体数量",
"keys": [
"县市数量"
]
},
{
"label": "可信度",
"pages": [
"关键节点",
"武器装备",
"组织与单位"
],
"description": "该条信息的公开来源可信程度评价",
"keys": [
"可信度中文"
]
},
{
"label": "备注",
"pages": [
"武器装备"
],
"description": "补充备注信息",
"keys": [
"notes"
]
},
{
"label": "安全边界",
"pages": [
"组织与单位"
],
"description": "该类信息仅用于公共研究、不涉及敏感细节的边界说明",
"keys": []
},
{
"label": "当前状态",
"pages": [
"组织与单位"
],
"description": "单位当前的状态(运作、撤编等)",
"keys": [
"当前状态"
]
},
{
"label": "所属区域",
"pages": [
"关键节点"
],
"description": "节点所在的行政区域",
"keys": [
"区域中文"
]
},
{
"label": "支撑区域",
"pages": [
"沿岸区域"
],
"description": "该区域提供支撑保障的区域列表",
"keys": [
"支撑区域"
]
},
{
"label": "日期",
"pages": [
"演习与项目"
],
"description": "事件或项目发生的日期",
"keys": [
"日期显示"
]
},
{
"label": "替代与备援",
"pages": [
"关键节点"
],
"description": "节点故障时的替代与备援方案",
"keys": [
"替代备援"
]
},
{
"label": "来源",
"pages": [
"关键节点",
"武器装备",
"演习与项目"
],
"description": "资料出处(公开报道、官方文件等)",
"keys": [
"来源信息"
]
},
{
"label": "来源与核实",
"pages": [
"组织与单位"
],
"description": "资料出处以及核实情况的说明",
"keys": []
},
{
"label": "来源数量",
"pages": [
"组织与单位"
],
"description": "该条信息引用的公开来源数量",
"keys": [
"来源"
]
},
{
"label": "架构层级",
"pages": [
"组织与单位"
],
"description": "单位在组织架构中的层级",
"keys": [
"架构层级"
]
},
{
"label": "核实日期",
"pages": [
"武器装备",
"沿岸区域",
"组织与单位"
],
"description": "信息最近一次核实的时间",
"keys": [
"核实日期"
]
},
{
"label": "沿海县市",
"pages": [
"沿岸区域"
],
"description": "区域内的沿海县市名称列表",
"keys": [
"沿海县市"
]
},
{
"label": "海岸分段数量",
"pages": [
"沿岸区域"
],
"description": "海岸线被划分成研究分段的数目",
"keys": [
"分段数量"
]
},
{
"label": "海岸形态",
"pages": [
"沿岸区域"
],
"description": "海岸的地理形态分类(如沙滩、礁岸等)",
"keys": [
"海岸形态"
]
},
{
"label": "海岸研究分段",
"pages": [
"沿岸区域"
],
"description": "按海岸形态划分的研究分段明细",
"keys": [
"segment_ids"
]
},
{
"label": "演训与事件",
"pages": [
"组织与单位"
],
"description": "与该单位相关的演习与事件",
"keys": [
"演训与事件"
]
},
{
"label": "状态",
"pages": [
"演习与项目"
],
"description": "当前状态(如进行中、已完成等)",
"keys": [
"状态中文"
]
},
{
"label": "生命周期解释",
"pages": [
"武器装备"
],
"description": "对装备所处生命周期阶段的解释说明",
"keys": []
},
{
"label": "直接下级机构或单位",
"pages": [
"组织与单位"
],
"description": "直接下辖的下级机构或单位列表",
"keys": [
"下级机构"
]
},
{
"label": "研究边界",
"pages": [
"关键节点",
"沿岸区域"
],
"description": "本页面内容的研究范围与不涉及事项说明",
"keys": []
},
{
"label": "空间范围",
"pages": [
"沿岸区域"
],
"description": "该区域覆盖的地理空间范围描述",
"keys": [
"scope"
]
},
{
"label": "组织与单位概况",
"pages": [
"组织与单位"
],
"description": "单位的基本属性概览",
"keys": []
},
{
"label": "组织关系",
"pages": [
"组织与单位"
],
"description": "单位的上级、下级与协作关系",
"keys": []
},
{
"label": "组织或单位类型",
"pages": [
"组织与单位"
],
"description": "条目的类型(机构、单位、节点档案等)",
"keys": [
"单位类型"
]
},
{
"label": "节点层级",
"pages": [
"关键节点"
],
"description": "节点在基础设施体系中的层级",
"keys": [
"层级中文"
]
},
{
"label": "节点概况",
"pages": [
"关键节点"
],
"description": "关键节点的基本属性概览",
"keys": []
},
{
"label": "节点类别",
"pages": [
"关键节点"
],
"description": "节点的类别(港口、能源、通信等)",
"keys": [
"类别中文"
]
},
{
"label": "行政上级",
"pages": [
"组织与单位"
],
"description": "行政隶属关系上的上级单位",
"keys": [
"行政上级"
]
},
{
"label": "装备概况",
"pages": [
"武器装备"
],
"description": "装备的基本属性概览",
"keys": []
},
{
"label": "装备类别",
"pages": [
"武器装备",
"组织与单位"
],
"description": "相关装备的类别划分",
"keys": [
"类别中文"
]
},
{
"label": "记录类别",
"pages": [
"组织与单位"
],
"description": "条目在库中的记录分类",
"keys": [
"记录类别"
]
},
{
"label": "资料来源",
"pages": [
"沿岸区域"
],
"description": "区域资料的公开来源",
"keys": []
},
{
"label": "资料缺口",
"pages": [
"组织与单位"
],
"description": "尚缺失、待补充的资料说明",
"keys": [
"资料缺口"
]
},
{
"label": "资料说明",
"pages": [
"关键节点"
],
"description": "资料的补充说明文字",
"keys": [
"note"
]
},
{
"label": "资料边界",
"pages": [
"武器装备"
],
"description": "装备资料仅基于公开信息的范围说明",
"keys": []
},
{
"label": "运行状态",
"pages": [
"关键节点"
],
"description": "节点当前的运行状态",
"keys": [
"状态中文"
]
},
{
"label": "适用范围",
"pages": [
"组织与单位"
],
"description": "该内容适用的范围",
"keys": [
"适用范围"
]
}
]
}
@@ -1 +0,0 @@
{"系统": {"名称": "台湾滨海防卫知识数据展示系统", "版本": "2.0", "知识库版本": "2.0", "更新时间": "2026-06-23", "主题": {"模式": "红白", "主色": "#c8102e", "深红": "#8f1024", "浅红": "#fff1f3", "背景": "#f7f7f8", "卡片": "#ffffff", "文字": "#262626"}}, "页面": [{"编号": "overview", "名称": "总览", "数据源": "data/overview.json"}, {"编号": "regions", "名称": "沿岸区域", "数据源": "data/regions.json"}, {"编号": "org_units", "名称": "组织与单位", "数据源": "data/org_units.json", "架构数据": "data/organization_charts.json"}, {"编号": "equipment", "名称": "武器装备", "数据源": "data/equipment.json"}, {"编号": "key_nodes", "名称": "关键节点", "数据源": "data/key_nodes.json"}, {"编号": "events_projects", "名称": "演习与项目", "数据源": "data/events_projects.json"}], "交互规则": {"默认展示": "卡片与图形化面板", "列表限制": "不超过六个核心字段", "快速预览": "右侧抽屉", "复杂档案": "二级详情页", "长数组": "分组卡片或弹框", "查询": "各页面独立关键词、分类筛选和组合条件查询", "禁止展示": ["精确坐标", "实时部署", "兵力库存", "通信拓扑", "行动路线", "脆弱点排序"]}}
@@ -1,336 +0,0 @@
/* no.1 页面运行时(结构化模板)。
* 由 template_fill.compose_structured 内联进骨架的运行时注入点。
* 数据从骨架里的两个 JSON 元素读取:
* DATA ← <script type="application/json" id="page-data">
* MANIFEST ← <script type="application/json" id="page-manifest">
* 渲染逻辑与单文件版一致(全局函数 + inline onclick),末尾附页面 JSON 编辑器。 */
const DATA=JSON.parse(document.getElementById("page-data").textContent);
const MANIFEST=JSON.parse(document.getElementById("page-manifest").textContent);
const QUERY_SCHEMA={"版本": "2.0", "说明": "前端以中文条件构造器生成内部查询对象,不设置独立全库检索页面。", "查询域": {"沿岸区域": {"数据源": "regions", "字段": [{"字段": "名称", "显示名": "区域名称", "操作": ["包含", "等于"]}, {"字段": "沿海县市", "显示名": "沿海县市", "操作": ["包含"]}, {"字段": "海岸形态", "显示名": "海岸形态", "操作": ["包含"]}]}, "组织与单位": {"数据源": "org_units", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含", "等于"]}, {"字段": "记录类别", "显示名": "记录类别", "操作": ["等于"]}, {"字段": "体系", "显示名": "体系", "操作": ["等于", "属于"]}, {"字段": "架构层级", "显示名": "架构层级", "操作": ["等于"]}, {"字段": "区域关联", "显示名": "区域关联", "操作": ["包含"]}, {"字段": "当前状态", "显示名": "当前状态", "操作": ["等于"]}]}, "武器装备": {"数据源": "equipment", "字段": [{"字段": "名称", "显示名": "装备名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "装备类别", "操作": ["等于"]}, {"字段": "使用体系中文", "显示名": "使用体系", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "公开状态", "操作": ["等于"]}]}, "关键节点": {"数据源": "key_nodes", "字段": [{"字段": "名称", "显示名": "节点名称", "操作": ["包含"]}, {"字段": "类别中文", "显示名": "节点类别", "操作": ["等于"]}, {"字段": "区域中文", "显示名": "所属区域", "操作": ["包含"]}, {"字段": "状态中文", "显示名": "运行状态", "操作": ["等于"]}]}, "演习与项目": {"数据源": "events_projects", "字段": [{"字段": "名称", "显示名": "名称", "操作": ["包含"]}, {"字段": "类型中文", "显示名": "事件类型", "操作": ["等于"]}, {"字段": "类别中文", "显示名": "项目类别", "操作": ["等于"]}, {"字段": "状态中文", "显示名": "状态", "操作": ["等于"]}]}}, "内部查询对象": {"数据域": "页面数据源编号", "关键词": "可选", "条件": [{"字段": "中文字段名", "操作": "包含或等于", "值": "查询值"}], "分页": {"页码": 1, "每页": 18}}};
const state={tab:"overview",mode:"chart",chart:"national_defense",q:"",filters:{},conditions:[],page:1,pageSize:18};
function e(v){return String(v??"").replace(/[&<>"']/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[m]))}
function a(v){return Array.isArray(v)?v:(v===undefined||v===null||v===""?[]:[v])}
function text(v){return Array.isArray(v)?v.join(" "):typeof v==="object"&&v?JSON.stringify(v):String(v??"")}
function chips(v,red=false,max=8){const x=a(v);return x.slice(0,max).map(y=>`<span class="chip ${red?"chip-red":""}">${e(y)}</span>`).join("")+(x.length>max?`<span class="chip">另${x.length-max}项</span>`:"")}
function statusChip(v){const green=/运行|列装|官方|确认|通过/.test(v||"");const orange=/规划|测试|建设|扩建|核验|有限/.test(v||"");return `<span class="chip ${green?"chip-green":orange?"chip-orange":"chip-red"}">${e(v||"未标注")}</span>`}
function unique(data,field){const s=new Set();data.forEach(x=>a(x[field]).forEach(v=>v&&s.add(v)));return [...s].sort()}
function setTab(tab){state.tab=tab;state.q="";state.filters={};state.conditions=[];state.page=1;location.hash="#/"+tab;render()}
function nav(){
document.getElementById("tabs").innerHTML=MANIFEST.页面.map(x=>`<button class="tab ${state.tab===x.编号?"active":""}" onclick="setTab('${x.编号}')">${x.名称}</button>`).join("")
}
function hero(title,desc,actions=""){return `<div class="hero"><div><h2>${title}</h2><p>${desc}</p><div style="margin-top:14px">${actions}</div></div><div class="hero-mark"></div></div>`}
function renderCustomModules(){
var list=a(MANIFEST.自定义模块).filter(function(m){return m&&m.页面===state.tab&&m.html});
if(!list.length)return;
var host=document.getElementById("customModules");
if(!host){host=document.createElement("div");host.id="customModules";document.getElementById("page").appendChild(host)}
host.innerHTML=list.map(function(m){return '<section class="detail-section" style="margin-top:16px"><h3>'+e(m.标题||"自定义模块")+'</h3>'+m.html+'</section>'}).join("")
}
function render(){nav();document.getElementById("page").style.display="block";document.getElementById("detailPage").classList.remove("show");const fn=window["render_"+state.tab]||render_overview;fn();renderCustomModules()}
function render_overview(){
const o=DATA.overview;
const metrics=o.核心指标.map(x=>`<div class="metric"><div class="metric-label">${x.名称}</div><div class="metric-value">${x.数值}<span class="metric-unit">${x.单位}</span></div><div class="metric-note">${x.说明}</div></div>`).join("");
const max=Math.max(...o.组织体系分布.map(x=>x.数值));
const bars=o.组织体系分布.slice(0,10).map(x=>`<div class="bar-row"><span>${e(x.名称)}</span><div class="bar"><i style="width:${x.数值/max*100}%"></i></div><b>${x.数值}</b></div>`).join("");
const entries=o.业务入口.map(x=>`<div class="entry-card" onclick="setTab('${x.页面}')"><b>${x.名称}</b><span class="entry-num">${x.数量}</span><p>${x.说明}</p></div>`).join("");
document.getElementById("page").innerHTML=hero("知识库业务总览","以区域、组织与单位、装备、公共关键节点、演习和建设项目为主线组织知识数据。")+
`<div class="metrics">${metrics}</div><div class="grid2"><div class="panel"><div class="panel-title"><span>组织体系分布</span></div>${bars}</div><div class="panel"><div class="panel-title"><span>业务入口</span></div><div class="entry-grid">${entries}</div></div></div>`;
}
function toolbar(domain,data,filterFields){
const selects=filterFields.map(f=>`<select data-filter="${f}"><option value="">${f}:全部</option>${unique(data,f).slice(0,120).map(v=>`<option value="${e(v)}" ${state.filters[f]===String(v)?"selected":""}>${e(v)}</option>`).join("")}</select>`).join("");
return `<div class="toolbar"><input id="keyword" value="${e(state.q)}" placeholder="输入关键词查询当前页面">${selects}<button class="btn btn-soft" onclick="openQueryBuilder('${domain}')">组合查询</button><button class="btn" onclick="clearQuery()">清空</button><span class="toolbar-count" id="count"></span></div>`;
}
function bindToolbar(domain,renderFn){
const q=document.getElementById("keyword");if(q)q.oninput=()=>{state.q=q.value;state.page=1;renderFn()};
document.querySelectorAll("[data-filter]").forEach(s=>s.onchange=()=>{state.filters[s.dataset.filter]=s.value;state.page=1;renderFn()});
}
function clearQuery(){state.q="";state.filters={};state.conditions=[];state.page=1;render()}
function matches(item,fields){
const q=state.q.trim().toLowerCase();
if(q&&!fields.some(f=>text(item[f]).toLowerCase().includes(q)))return false;
for(const [f,v] of Object.entries(state.filters)){if(v&&!a(item[f]).some(x=>String(x)===String(v)))return false}
for(const c of state.conditions){
const value=item[c.field],all=a(value);
if(c.op==="包含"&&!all.some(x=>String(x).includes(c.value)))return false;
if(c.op==="等于"&&!all.some(x=>String(x)===c.value))return false;
if(c.op==="属于"&&!all.some(x=>c.value.split(/[、,,]/).map(y=>y.trim()).includes(String(x))))return false;
}
return true
}
function paginate(items){const start=(state.page-1)*state.pageSize;return items.slice(start,start+state.pageSize)}
function pagination(total,rerender){const pages=Math.max(1,Math.ceil(total/state.pageSize));return `<div class="pagination"><button class="btn" ${state.page<=1?"disabled":""} onclick="state.page--;${rerender}">上一页</button><span class="page-no">第 ${state.page} / ${pages} 页</span><button class="btn" ${state.page>=pages?"disabled":""} onclick="state.page++;${rerender}">下一页</button></div>`}
function render_regions(){
const cards=DATA.regions.map(r=>`<article class="region-card" onclick="openDetail('regions','${r.编号}')"><h3>${r.编号} · ${r.名称}</h3><p>${e(r.档案摘要)}</p><div>${chips(r.海岸形态,true,3)}</div><div class="region-stats"><div><b>${r.县市数量}</b><br><small>县市子库</small></div><div><b>${r.分段数量}</b><br><small>海岸分段</small></div></div></article>`).join("");
document.getElementById("page").innerHTML=hero("沿岸区域","采用九个综合研究区,下钻到沿海县市和海岸研究分段。")+`<div class="region-grid">${cards}</div>`;
}
function render_org_units(){
const chartActive=state.mode==="chart",cardsActive=state.mode==="cards";
document.getElementById("page").innerHTML=hero("组织与单位","组织架构与单位档案使用同一套280个唯一节点。点击架构节点或机构卡片可进入完整档案。")+
`<div class="segmented"><button class="seg-btn ${chartActive?"active":""}" onclick="state.mode='chart';render_org_units()">组织架构图</button><button class="seg-btn ${cardsActive?"active":""}" onclick="state.mode='cards';render_org_units()">机构与单位卡片</button></div><div id="orgBody"></div>`;
if(chartActive)renderOrgChart();else renderOrgCards(true);
}
function renderOrgChart(){
const views=DATA.organization_charts;
const current=views.find(x=>x.id===state.chart)||views[0];
const buttons=views.map(x=>`<button class="chart-view ${x.id===current.id?"active":""}" onclick="state.chart='${x.id}';renderOrgChart()">${x.名称}</button>`).join("");
const tree=`<div class="org-chart"><ul><li>${chartNode(current.tree)}</li></ul></div>`;
const roots=DATA.org_units.filter(x=>!x.行政上级编号).length;
document.getElementById("orgBody").innerHTML=`<div class="chart-toolbar">${buttons}</div><div class="panel" style="margin-bottom:14px"><div class="panel-title"><span>${current.名称}</span></div><p style="color:#777;font-size:13px">${current.说明}</p></div><div class="summary-strip"><div class="summary-card"><b>${DATA.org_units.length}</b><span>唯一组织节点</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="单位").length}</b><span>含深度单位档案</span></div><div class="summary-card"><b>${DATA.org_units.filter(x=>x.记录类别==="机构").length}</b><span>制度与组织节点</span></div><div class="summary-card"><b>${roots}</b><span>顶层或独立根节点</span></div></div><div class="org-chart-wrap">${tree}</div>`;
}
function chartNode(n){
if(!n)return "";
const children=(n.children||[]).length?`<ul>${n.children.map(c=>`<li>${chartNode(c)}</li>`).join("")}</ul>`:"";
return `<div class="org-node" onclick="openDetail('org_units','${n.id}')"><b>${e(n.名称)}</b><small>${e(n.类型)}</small><div class="node-kind">${e(n.记录类别)} · ${e(n.状态)}</div></div>${children}`;
}
function renderOrgCards(initial=false){
const data=DATA.org_units;
if(initial){
document.getElementById("orgBody").innerHTML=toolbar("组织与单位",data,["记录类别","体系","架构层级","当前状态"])+`<div id="orgCards"></div>`;
bindToolbar("组织与单位",()=>renderOrgCards(false));
}
const filtered=data.filter(x=>matches(x,["名称","档案摘要","体系","架构层级","公开任务","区域关联","装备类别"]));
const cards=paginate(filtered).map(x=>`<article class="data-card" onclick="openDetail('org_units','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.体系)} · ${e(x.架构层级)}</div></div><span class="card-type">${e(x.记录类别)}</span></div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开任务).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.行政上级)}</span><span>${e(x.当前状态)}</span></div></article>`).join("");
document.getElementById("orgCards").innerHTML=`<div class="card-grid">${cards||'<div class="empty">没有匹配结果</div>'}</div>${pagination(filtered.length,"renderOrgCards(false)")}`;
const count=document.getElementById("count");if(count)count.textContent=`共 ${filtered.length} 个节点`;
}
function categorySummary(data,field,onClick){
const counts={};data.forEach(x=>{const k=x[field]||"未分类";counts[k]=(counts[k]||0)+1});
return `<div class="category-grid">${Object.entries(counts).sort((a,b)=>b[1]-a[1]).slice(0,10).map(([k,v])=>`<div class="category-card" onclick="${onClick}('${e(k)}')"><b>${v}</b><span>${e(k)}</span></div>`).join("")}</div>`
}
function render_equipment(initial=true){
const data=DATA.equipment;
document.getElementById("page").innerHTML=hero("武器装备","以装备卡片展示类别、使用体系、公开作用和生命周期状态,不把规划、采购、测试和列装混为一谈。")+
categorySummary(data,"类别中文","filterEquipmentCategory")+toolbar("武器装备",data,["类别中文","状态中文","可信度中文"])+`<div id="equipmentCards"></div>`;
bindToolbar("武器装备",()=>renderEquipmentCards());
renderEquipmentCards();
}
function filterEquipmentCategory(v){state.filters["类别中文"]=v;state.page=1;render_equipment()}
function renderEquipmentCards(){
const data=DATA.equipment.filter(x=>matches(x,["名称","别名","类别中文","使用体系中文","公开作用","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('equipment','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${a(x.使用体系中文).join("、")}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-roles">${a(x.公开作用).slice(0,3).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>可信度:${e(x.可信度中文)}</span><span>${e(x.verified_at||"未标注")}</span></div></article>`).join("");
document.getElementById("equipmentCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderEquipmentCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 项装备`;
}
function render_key_nodes(){
const data=DATA.key_nodes;
document.getElementById("page").innerHTML=hero("关键节点","从公共功能和韧性角度展示港口、机场、交通、能源、通信、供水、医疗和产业节点。")+
categorySummary(data,"类别中文","filterNodeCategory")+toolbar("关键节点",data,["类别中文","层级中文","状态中文","可信度中文"])+`<div id="nodeCards"></div>`;
bindToolbar("关键节点",()=>renderNodeCards());renderNodeCards();
}
function filterNodeCategory(v){state.filters["类别中文"]=v;state.page=1;render_key_nodes()}
function renderNodeCards(){
const data=DATA.key_nodes.filter(x=>matches(x,["名称","类别中文","区域中文","主管运营","公共功能","状态中文"]));
const html=paginate(data).map(x=>`<article class="data-card" onclick="openDetail('key_nodes','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)} · ${e(x.层级中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div>${chips(x.区域中文,true,3)}</div><div class="card-roles">${a(x.公共功能).slice(0,2).map(y=>`<div class="role-line">${e(y)}</div>`).join("")}</div><div class="card-footer"><span>${e(x.主管运营)}</span><span>可信度:${e(x.可信度中文)}</span></div></article>`).join("");
document.getElementById("nodeCards").innerHTML=`<div class="card-grid">${html||'<div class="empty">没有匹配结果</div>'}</div>${pagination(data.length,"renderNodeCards()")}`;
const c=document.getElementById("count");if(c)c.textContent=`共 ${data.length} 个节点`;
}
function render_events_projects(){
const events=DATA.events.map(x=>`<article class="time-card" onclick="openDetail('events','${x.id}')"><div class="time-date">${e(x.日期显示)}</div><h3 style="margin:0 0 7px">${e(x.名称)}</h3><div>${chips([x.类型中文,...a(x.区域中文)],true,4)}</div><p>${e(x.档案摘要)}</p></article>`).join("");
const projects=DATA.projects.map(x=>`<article class="data-card" onclick="openDetail('projects','${x.id}')"><div class="card-top"><div><h3>${e(x.名称)}</h3><div class="card-sub">${e(x.类别中文)}</div></div>${statusChip(x.状态中文)}</div><p>${e(x.档案摘要)}</p><div class="card-footer"><span>建设项目</span><span>${e(x.状态中文)}</span></div></article>`).join("");
document.getElementById("page").innerHTML=hero("演习与项目","演习采用时间线,建设项目采用卡片,分别表达日期型事件和生命周期型项目。")+
`<div class="grid2"><section class="panel"><div class="panel-title"><span>演习与重要事件</span></div><div class="timeline">${events}</div></section><section class="panel"><div class="panel-title"><span>现代化与建设项目</span></div><div class="card-grid" style="grid-template-columns:1fr">${projects}</div></section></div>`;
}
function findItem(domain,id){
if(domain==="regions")return DATA.regions.find(x=>x.编号===id);
if(domain==="org_units")return DATA.org_units.find(x=>x.id===id);
if(domain==="equipment")return DATA.equipment.find(x=>x.id===id);
if(domain==="key_nodes")return DATA.key_nodes.find(x=>x.id===id);
if(domain==="events")return DATA.events.find(x=>x.id===id);
if(domain==="projects")return DATA.projects.find(x=>x.id===id);
}
function openDetail(domain,id){
const x=findItem(domain,id);if(!x)return;
document.getElementById("page").style.display="none";
const d=document.getElementById("detailPage");d.classList.add("show");
if(domain==="org_units")d.innerHTML=detailOrg(x);
else if(domain==="regions")d.innerHTML=detailRegion(x);
else if(domain==="equipment")d.innerHTML=detailEquipment(x);
else if(domain==="key_nodes")d.innerHTML=detailNode(x);
else d.innerHTML=detailEventProject(x,domain);
location.hash=`#/detail/${domain}/${encodeURIComponent(id)}`;
window.scrollTo({top:0});
}
function back(){document.getElementById("detailPage").classList.remove("show");document.getElementById("page").style.display="block";location.hash="#/"+state.tab}
function F(label){var cfg=MANIFEST.字段显示;return !(cfg&&cfg[label]===false)}
function info(label,value){if(!F(label))return "";return `<div class="info-item"><label>${e(label)}</label><div>${value===undefined||value===null||value===""?'<span style="color:#999">未公开或未标注</span>':value}</div></div>`}
function listBlock(values){const v=a(values);return v.length?`<div class="text-list">${v.map(x=>`<div class="text-item">${e(x)}</div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立条目</div>`}
function section(title,body){if(!F(title))return "";return `<section class="detail-section"><h3>${title}</h3>${body}</section>`}
function detailHeader(title,summary,tags){return `<span class="back-link" onclick="back()" style="color:var(--red);cursor:pointer;display:inline-block;margin-bottom:12px">← 返回上一页</span><div class="detail-hero"><h2>${e(title)}</h2><p>${e(summary)}</p><div class="detail-tags">${chips(tags,false,10)}</div></div>`}
function detailOrg(x){
const core=section("组织与单位概况",`<div class="info-grid">${info("记录类别",e(x.记录类别))}${info("体系",e(x.体系))}${info("架构层级",e(x.架构层级))}${info("组织或单位类型",e(x.单位类型))}${info("当前状态",statusChip(x.当前状态))}${info("可信度",e(x.可信度))}${info("行政上级",e(x.行政上级))}${info("军令或任务上级",e(x.军令或任务上级))}</div>`);
const tasks=section("公开职掌与任务",listBlock(x.公开任务));
const assoc=section("区域、装备与演训关联",`<div class="info-grid">${info("区域关联",chips(x.区域关联,true,12))}${info("装备类别",chips(x.装备类别,true,12))}${info("演训与事件",chips(x.演训与事件,true,12))}${info("适用范围",e(x.适用范围))}</div>`);
const childrenSec=section("直接下级机构或单位",x.下级机构.length?`<div class="mini-card-grid">${x.下级机构.map((n,i)=>`<div class="mini-card" ${x.下级机构编号[i]?`onclick="openDetail('org_units','${x.下级机构编号[i]}')" style="cursor:pointer"`:""}><b>${e(n)}</b><span style="font-size:11px;color:#888">点击查看档案</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">未列出直接下级</div>`);
const relSec=section("组织关系",x.关系.length?`<div class="mini-card-grid">${x.关系.slice(0,16).map(r=>`<div class="mini-card"><b>${e(r.关系)}</b><div style="font-size:12px;line-height:1.65">${e(r.主体)} → ${e(r.客体)}</div><span style="font-size:11px;color:#888">${e(r.可信度)}</span></div>`).join("")}</div>`:`<div class="empty" style="padding:20px">暂无独立关系条目</div>`);
const history=section("历史沿革与说明",`<div class="text-item">${e(x.历史沿革)}</div>${x.说明?`<div class="text-item">${e(x.说明)}</div>`:""}`);
const gaps=section("资料缺口",`<div class="warning-box">${a(x.资料缺口).map(y=>`• ${e(y)}`).join("<br>")}</div>`);
const sources=section("来源与核实",`<div class="info-grid">${info("核实日期",e(x.核实日期))}${info("来源数量",String(a(x.来源).length))}</div><div style="margin-top:10px">${a(x.来源信息||x.来源).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">尚未列出独立来源编号</div>'}</div>`);
const safe=section("安全边界",`<div class="safe-box">${e(x.安全边界)}</div>`);
return detailHeader(x.名称,x.档案摘要,[x.记录类别,x.体系,x.当前状态])+`<div class="detail-grid"><div>${core}${tasks}${assoc}${childrenSec}${relSec}</div><aside>${history}${sources}${gaps}${safe}</aside></div>`;
}
function detailRegion(x){
const countyCards=x.县市子库.map(c=>`<div class="mini-card"><b>${e(c.name||c.county_name||c.名称||"县市子库")}</b><div style="font-size:12px;color:#777">${e(c.scope||c.沿岸范围||"沿岸县市档案")}</div></div>`).join("");
const segCards=x.海岸分段.map(s=>`<div class="mini-card"><b>${e(s.name||s.名称)}</b><div>${chips([s.coastal_type||s.海岸类型,s.所属区域],true,3)}</div><p style="font-size:11px;color:#777">${e(s.scope||s.空间范围||"")}</p></div>`).join("");
return detailHeader(x.名称,x.档案摘要,[x.编号,...a(x.海岸形态)])+
`<div class="detail-grid"><div>${section("区域基本信息",`<div class="info-grid">${info("空间范围",e(x.scope))}${info("县市数量",String(x.县市数量))}${info("海岸分段数量",String(x.分段数量))}${info("核实日期",e(x.last_verified||"未标注"))}${info("沿海县市",chips(x.沿海县市,true,15))}${info("支撑区域",chips(x.支撑区域,true,15))}</div>`)}${section("海岸形态",listBlock(x.海岸形态))}${section("海岸研究分段",`<div class="mini-card-grid">${segCards}</div>`)}</div><aside>${section("县市子库",`<div class="mini-card-grid" style="grid-template-columns:1fr">${countyCards}</div>`)}${section("资料来源",`<div>${a(x.来源信息||x.source_ids).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待进一步补充</div>'}</div>`)}${section("研究边界",`<div class="safe-box">区域分段用于公开空间、治理和韧性研究,不等于军事防区、实际部署边界或行动分区。</div>`)}</aside></div>`;
}
function detailEquipment(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.状态中文,...a(x.使用体系中文)])+
`<div class="detail-grid"><div>${section("装备概况",`<div class="info-grid">${info("装备类别",e(x.类别中文))}${info("使用体系",chips(x.使用体系中文,true,10))}${info("公开状态",statusChip(x.状态中文))}${info("可信度",e(x.可信度中文))}${info("别名",chips(x.aliases,true,10))}${info("核实日期",e(x.verified_at||"未标注"))}</div>`)}${section("公开作用",listBlock(x.公开作用))}${section("生命周期解释",`<div class="text-list"><div class="text-item">规划、预算、签约、交付、接装、训练、测评和形成能力属于不同阶段。</div><div class="text-item">本条目的当前状态为:${e(x.状态中文)}。</div><div class="text-item">装备价值需与感知、指挥、平台、保障和训练共同评估。</div></div>`)}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("备注",`<div class="text-item">${e(x.notes||"无额外公开说明。")}</div>`)}${section("资料边界",`<div class="safe-box">不记录或推断库存、精确部署、妥善率、射界、航线、战法和单位实际配赋。</div>`)}</aside></div>`;
}
function detailNode(x){
return detailHeader(x.名称,x.档案摘要,[x.类别中文,x.层级中文,x.状态中文])+
`<div class="detail-grid"><div>${section("节点概况",`<div class="info-grid">${info("节点类别",e(x.类别中文))}${info("节点层级",e(x.层级中文))}${info("所属区域",chips(x.区域中文,true,10))}${info("运行状态",statusChip(x.状态中文))}${info("主管或运营主体",e(x.主管运营))}${info("可信度",e(x.可信度中文))}</div>`)}${section("公共功能",listBlock(x.公共功能))}${section("上游依赖",listBlock(x.依赖类别))}${section("替代与备援",listBlock(x.替代备援))}</div><aside>${section("来源",a(x.来源信息||x.sources).map(s=>`<div class="source-line">${e(s)}</div>`).join("")||'<div class="source-line">待补</div>')}${section("资料说明",`<div class="text-item">${e(x.note||"无额外公开说明。")}</div>`)}${section("研究边界",`<div class="safe-box">节点用于公共功能、治理和韧性研究,不展示精确坐标、内部拓扑、实时容量、库存、脆弱点或攻击价值。</div>`)}</aside></div>`;
}
function detailEventProject(x,domain){
const isEvent=domain==="events";
return detailHeader(x.名称,x.档案摘要,[isEvent?x.类型中文:x.类别中文,x.状态中文])+
`<div class="detail-grid"><div>${section(isEvent?"事件信息":"项目信息",`<div class="info-grid">${info(isEvent?"事件类型":"项目类别",e(isEvent?x.类型中文:x.类别中文))}${info("状态",statusChip(x.状态中文))}${info("日期",e(x.日期显示||"不适用"))}${info("区域",chips(x.区域中文,true,12))}</div>`)}${section(isEvent?"主题与验证重点":"项目评估链条",isEvent?listBlock(x.topics):`<div class="text-list"><div class="text-item">规划与需求确认</div><div class="text-item">预算与采购</div><div class="text-item">生产、交付与接装</div><div class="text-item">训练、测评、保障与形成能力</div></div>`)}</div><aside>${section("来源",`<div class="source-line">${e(a(x.来源信息).join(";")||x.source||"待补")}</div>`)}${section("分析限制",`<div class="safe-box">${isEvent?"单次演习不能直接证明永久编制、完整战备或真实作战效果。":"项目存在不等于已经交付、接装或形成能力。"}</div>`)}</aside></div>`;
}
function openQueryBuilder(domainName){
const conf=QUERY_SCHEMA.查询域[domainName];if(!conf)return;
const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");
const rows=(state.conditions.length?state.conditions:[{field:conf.字段[0].字段,op:"包含",value:""}]).map((c,i)=>queryRow(opts,c,i)).join("");
openModal(`<h2 style="margin-top:0">组合条件查询</h2><p style="color:#777;font-size:13px">使用中文字段构造条件,查询只作用于当前业务页面。</p><div id="queryRows">${rows}</div><button class="btn" onclick="addQueryRow('${domainName}')">增加条件</button> <button class="btn btn-red" onclick="applyQuery('${domainName}')">应用查询</button>`);
}
function queryRow(opts,c,i){return `<div class="query-row" data-query-row><select class="q-field">${opts.replace(`value="${c.field}"`,`value="${c.field}" selected`)}</select><select class="q-op"><option ${c.op==="包含"?"selected":""}>包含</option><option ${c.op==="等于"?"selected":""}>等于</option><option ${c.op==="属于"?"selected":""}>属于</option></select><input class="q-value" value="${e(c.value)}" placeholder="输入查询值"><button class="btn" onclick="this.parentElement.remove()">×</button></div>`}
function addQueryRow(domainName){const conf=QUERY_SCHEMA.查询域[domainName];const opts=conf.字段.map(x=>`<option value="${e(x.字段)}">${e(x.显示名)}</option>`).join("");document.getElementById("queryRows").insertAdjacentHTML("beforeend",queryRow(opts,{field:conf.字段[0].字段,op:"包含",value:""},99))}
function applyQuery(){state.conditions=[...document.querySelectorAll("[data-query-row]")].map(r=>({field:r.querySelector(".q-field").value,op:r.querySelector(".q-op").value,value:r.querySelector(".q-value").value})).filter(x=>x.value);state.page=1;closeModal();render()}
function openModal(html){document.getElementById("modal").innerHTML=html;document.getElementById("modalOverlay").classList.add("show")}
function closeModal(){document.getElementById("modalOverlay").classList.remove("show")}
function closeDrawer(){document.getElementById("drawerOverlay").classList.remove("show")}
document.getElementById("modalOverlay").onclick=x=>{if(x.target.id==="modalOverlay")closeModal()};
document.getElementById("drawerOverlay").onclick=x=>{if(x.target.id==="drawerOverlay")closeDrawer()};
/* ── 页面 JSON 编辑器 ──────────────────────────────────────────────
* 模块 = DATA 顶层键(Tab 标签取 MANIFEST.页面 的中文名)+「系统设置」编辑 MANIFEST。
* 应用 → 原地替换 DATA/MANIFEST → 重置交互状态 → render() 整体重渲染;
* 修改按数据指纹存 localStorage(沙箱 iframe 禁存储时 try/catch 静默降级,仅会话内有效),
* 不回写服务器——服务端持久化走模板管理页「编辑数据」。 */
(function(){
"use strict";
function clone(v){return JSON.parse(JSON.stringify(v))}
function draftsToData(d){var o={};Object.keys(d||{}).forEach(function(k){if(k!=="__manifest__")o[k]=d[k]});return o}
function draftsFrom(d,m){var o={};Object.keys(d||{}).forEach(function(k){o[k]=d[k]});o.__manifest__=m;return o}
function replaceInPlace(target,src){Object.keys(target).forEach(function(k){delete target[k]});Object.keys(src).forEach(function(k){target[k]=src[k]})}
var initialData=clone(DATA),initialManifest=clone(MANIFEST);
var initialModules=Object.keys(DATA).concat(["__manifest__"]);
// 本地缓存键按初始数据指纹生成:模板/产物内容变了,旧缓存自然失效。
var fingerprint=(function(){var h=2166136261,s=JSON.stringify(initialData)+"|"+JSON.stringify(initialManifest);for(var i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return (h>>>0).toString(36)})();
var storageKey="structured-page-data-v1:"+fingerprint;
function readLS(k){try{return window.localStorage?window.localStorage.getItem(k):null}catch(x){return null}}
function writeLS(k,v){try{if(window.localStorage)window.localStorage.setItem(k,v);return true}catch(x){return false}}
function removeLS(k){try{if(window.localStorage)window.localStorage.removeItem(k)}catch(x){}}
// 启动恢复:有合法缓存则应用(本段在首帧 render() 之前执行)。
(function(){
var cached=readLS(storageKey);if(!cached)return;
try{
var parsed=JSON.parse(cached);
if(parsed&&typeof parsed==="object"&&parsed.data&&parsed.manifest&&initialModules.every(function(k){
return k==="__manifest__"||typeof parsed.data[k]==="object"&&parsed.data[k]&&!Array.isArray(parsed.data[k]);
})){
replaceInPlace(DATA,parsed.data);replaceInPlace(MANIFEST,parsed.manifest)
}
}catch(x){/* 缓存损坏则忽略,沿用模板默认数据 */}
})();
var labels={};a(MANIFEST.页面).forEach(function(p){if(p&&p.编号)labels[p.编号]=p.名称||p.编号});
var modules=Object.keys(DATA).concat(["__manifest__"]);
var active=modules[0]||"__manifest__";
var drafts=draftsFrom(clone(DATA),clone(MANIFEST));
function moduleTitle(key){return key==="__manifest__"?"系统设置(MANIFEST)":(labels[key]||key)}
function validDrafts(){return modules.every(function(k){var v=drafts[k];return v&&typeof v==="object"&&!Array.isArray(v)})}
var overlay=document.getElementById("editorOverlay");
if(!overlay)return; // 骨架缺编辑器标记(被手工裁掉)时安全退出
var tabsEl=document.getElementById("editorTabs");
var editor=document.getElementById("jsonEditor");
var statusEl=document.getElementById("jsonStatus");
var titleEl=document.getElementById("editorModuleTitle");
function setStatus(ok,msg){statusEl.textContent=msg;statusEl.className="json-status "+(ok?"valid":"invalid")}
function readEditor(){
try{
var parsed=JSON.parse(editor.value);
if(!parsed||typeof parsed!=="object"||Array.isArray(parsed))throw new Error("模块必须是 JSON 对象");
setStatus(true,"JSON 有效");return parsed
}catch(x){setStatus(false,"JSON 无效:"+x.message);return null}
}
function saveActiveDraft(){var parsed=readEditor();if(!parsed)return false;drafts[active]=parsed;return true}
function loadEditor(){titleEl.textContent=moduleTitle(active);editor.value=JSON.stringify(drafts[active],null,2);setStatus(true,"JSON 有效")}
function renderTabs(){
tabsEl.innerHTML="";
modules.forEach(function(key){
var b=document.createElement("button");b.type="button";
b.className="editor-tab"+(key===active?" active":"");
b.innerHTML="<b>"+e(moduleTitle(key))+"</b><small>"+(key==="__manifest__"?"MANIFEST":key)+"</small>";
b.onclick=function(){if(key===active||!saveActiveDraft())return;active=key;renderTabs();loadEditor()};
tabsEl.appendChild(b)
})
}
function closeEditor(){overlay.classList.remove("show")}
function applyToPage(){
if(!saveActiveDraft()||!validDrafts()){setStatus(false,"存在无效模块,请先修正");return}
replaceInPlace(DATA,draftsToData(drafts));
replaceInPlace(MANIFEST,drafts.__manifest__);
// 重置交互状态后整体重渲染(tab 回到第一个页面)。
state.tab=(MANIFEST.页面&&MANIFEST.页面[0]&&MANIFEST.页面[0].编号)||"overview";
state.mode="chart";state.chart="national_defense";state.q="";state.filters={};state.conditions=[];state.page=1;
closeEditor();render();syncHeader();
writeLS(storageKey,JSON.stringify({data:DATA,manifest:MANIFEST}))
}
function legacyCopy(text){var t=document.createElement("textarea");t.value=text;document.body.appendChild(t);t.select();try{document.execCommand("copy")}catch(x){}t.remove()}
function copyText(text,done){
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(done,function(){legacyCopy(text);done()})}
else{legacyCopy(text);done()}
}
document.getElementById("openEditor").onclick=function(){
drafts=draftsFrom(clone(DATA),clone(MANIFEST));
active=modules[0]||"__manifest__";
renderTabs();loadEditor();
overlay.classList.add("show");
setTimeout(function(){editor.focus()},40)
};
document.getElementById("closeEditor").onclick=closeEditor;
overlay.onclick=function(ev){if(ev.target===overlay)closeEditor()};
document.addEventListener("keydown",function(ev){if(ev.key==="Escape"&&overlay.classList.contains("show"))closeEditor()});
document.getElementById("applyJson").onclick=applyToPage;
document.getElementById("resetJson").onclick=function(){
drafts=draftsFrom(clone(initialData),clone(initialManifest));
active=modules[0]||"__manifest__";
removeLS(storageKey);
renderTabs();loadEditor();
setStatus(true,"已恢复默认数据,点击「应用到页面」生效")
};
document.getElementById("copyJson").onclick=function(){
if(!saveActiveDraft())return;
copyText(JSON.stringify({data:draftsToData(drafts),manifest:drafts.__manifest__},null,2),function(){})
};
document.getElementById("downloadJson").onclick=function(){
if(!saveActiveDraft())return;
var blob=new Blob([JSON.stringify({data:draftsToData(drafts),manifest:drafts.__manifest__},null,2)],{type:"application/json;charset=utf-8"});
var link=document.createElement("a");link.href=URL.createObjectURL(blob);link.download="page-data.json";link.click();
setTimeout(function(){URL.revokeObjectURL(link.href)},1000)
};
})();
function syncHeader(){
var sys=MANIFEST.系统||{};
if(sys.名称){document.title=String(sys.名称);var h1=document.querySelector(".title h1");if(h1)h1.textContent=String(sys.名称)}
var hs=document.querySelector(".header-state");
if(hs){var ver=sys.知识库版本,upd=sys.更新时间;if(ver||upd)hs.innerHTML="知识库版本 "+e(ver||"")+"<br>更新日期 "+e(upd||"")}
}
syncHeader();
const hash=location.hash.match(/^#\/([^/]+)/);if(hash&&MANIFEST.页面.some(x=>x.编号===hash[1]))state.tab=hash[1];render();
@@ -1,98 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>台湾滨海防卫知识数据展示系统</title>
<style>
:root{
--red:#c8102e;--red2:#8f1024;--light-red:#fff1f3;--soft-red:#fde7eb;
--bg:#f5f5f6;--card:#fff;--text:#262626;--muted:#737373;--line:#e5e5e5;
--green:#168a4a;--orange:#c56a00;--shadow:0 8px 24px rgba(74,12,24,.08)
}
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);font-family:"PingFang SC","Microsoft YaHei",Arial,sans-serif}
button,input,select{font:inherit}.header{position:sticky;top:0;z-index:30;background:#fff;border-bottom:1px solid var(--line);box-shadow:0 3px 12px rgba(0,0,0,.04)}
.header-top{height:70px;padding:0 28px;display:flex;align-items:center;gap:24px}.logo{width:42px;height:42px;border-radius:12px;background:linear-gradient(145deg,var(--red),var(--red2));display:grid;place-items:center;color:#fff;font-weight:800}
.title{flex:1}.title h1{font-size:19px;margin:0 0 5px}.title p{margin:0;font-size:12px;color:var(--muted)}.header-state{text-align:right;font-size:12px;color:var(--muted)}
.tabs{display:flex;padding:0 24px;gap:4px;overflow:auto}.tab{border:0;background:transparent;padding:13px 18px;color:#666;cursor:pointer;border-bottom:3px solid transparent;white-space:nowrap}.tab.active{color:var(--red);font-weight:700;border-bottom-color:var(--red);background:linear-gradient(to top,var(--light-red),transparent)}
.main{padding:24px 28px 48px}.hero{background:linear-gradient(135deg,#fff,#fff5f6);border:1px solid #f2d4d9;border-radius:20px;padding:24px;display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:18px;box-shadow:var(--shadow)}
.hero h2{margin:0 0 8px;font-size:26px}.hero p{margin:0;color:var(--muted);line-height:1.7}.hero-mark{width:90px;height:90px;border-radius:50%;background:radial-gradient(circle,#fff 0 30%,#fbd5dc 31% 55%,var(--red) 56%);opacity:.9}
.btn{border:1px solid #d8d8d8;background:#fff;color:#333;border-radius:9px;padding:8px 13px;cursor:pointer}.btn:hover{border-color:var(--red);color:var(--red)}.btn-red{background:var(--red);border-color:var(--red);color:#fff}.btn-red:hover{background:var(--red2);color:#fff}.btn-soft{background:var(--light-red);color:var(--red);border-color:#f0bec8}
.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.metric{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);position:relative;overflow:hidden}.metric:after{content:"";position:absolute;right:-20px;top:-30px;width:90px;height:90px;border-radius:50%;background:var(--light-red)}.metric-label{font-size:13px;color:var(--muted)}.metric-value{font-size:30px;font-weight:800;color:var(--red);margin-top:8px}.metric-unit{font-size:12px;margin-left:4px}.metric-note{font-size:12px;color:#888;margin-top:6px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}.panel{background:#fff;border:1px solid var(--line);border-radius:17px;padding:18px;box-shadow:var(--shadow)}.panel-title{font-weight:800;margin-bottom:14px;display:flex;align-items:center;justify-content:space-between}.panel-title:before{content:"";width:5px;height:18px;background:var(--red);border-radius:5px;margin-right:9px}.panel-title span:first-child{display:flex;align-items:center}
.bar-row{display:grid;grid-template-columns:150px 1fr 38px;gap:10px;align-items:center;font-size:12px;margin:10px 0}.bar{height:9px;background:#f1f1f1;border-radius:20px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--red),#ed6b7e);border-radius:20px}
.entry-grid,.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.entry-card,.data-card{background:#fff;border:1px solid var(--line);border-radius:16px;padding:17px;box-shadow:var(--shadow);cursor:pointer;transition:.18s}.entry-card:hover,.data-card:hover{transform:translateY(-2px);border-color:#e0a0ab}.entry-card b,.data-card h3{display:block;margin:0 0 8px}.entry-num{font-size:25px;color:var(--red);font-weight:800}.entry-card p,.data-card p{font-size:12px;color:var(--muted);line-height:1.65;margin:8px 0 0}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:14px;padding:12px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;margin-bottom:16px;box-shadow:var(--shadow)}.toolbar input,.toolbar select{height:37px;border:1px solid #ddd;border-radius:8px;background:#fff;padding:0 10px;color:#333}.toolbar input{flex:1;min-width:250px}.toolbar-count{margin-left:auto;color:var(--muted);font-size:12px}
.chip{display:inline-flex;align-items:center;border-radius:999px;padding:4px 9px;background:#f3f3f3;color:#555;font-size:11px;margin:2px}.chip-red{background:var(--light-red);color:var(--red)}.chip-green{background:#edf9f2;color:var(--green)}.chip-orange{background:#fff6e8;color:var(--orange)}
.region-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.region-card{background:#fff;border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--shadow);cursor:pointer;position:relative;overflow:hidden}.region-card:before{content:"";position:absolute;left:0;top:0;bottom:0;width:5px;background:var(--red)}.region-card h3{margin:0 0 8px;font-size:18px}.region-card p{font-size:12px;color:var(--muted);line-height:1.7}.region-stats{display:flex;gap:18px;margin-top:13px;border-top:1px solid #f0f0f0;padding-top:12px}.region-stats b{color:var(--red);font-size:18px}
.segmented{display:flex;gap:5px;background:#f0f0f0;padding:4px;border-radius:11px;width:max-content;margin-bottom:15px}.seg-btn{border:0;background:transparent;padding:8px 14px;border-radius:8px;color:#666;cursor:pointer}.seg-btn.active{background:#fff;color:var(--red);font-weight:700;box-shadow:0 2px 7px rgba(0,0,0,.08)}
.chart-toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}.chart-view{padding:9px 14px;border:1px solid #e1c3c9;background:#fff7f8;color:#8c2335;border-radius:9px;cursor:pointer}.chart-view.active{background:var(--red);color:#fff;border-color:var(--red)}
.org-chart-wrap{overflow:auto;background:#fff;border:1px solid var(--line);border-radius:18px;padding:28px;box-shadow:var(--shadow);min-height:420px}
.org-chart{display:flex;justify-content:center;min-width:max-content}.org-chart ul{padding-top:24px;position:relative;display:flex;justify-content:center;margin:0;padding-left:0}.org-chart li{list-style:none;text-align:center;position:relative;padding:24px 8px 0}.org-chart li:before,.org-chart li:after{content:"";position:absolute;top:0;right:50%;border-top:1px solid #c7a8ae;width:50%;height:24px}.org-chart li:after{right:auto;left:50%;border-left:1px solid #c7a8ae}.org-chart li:only-child:after,.org-chart li:only-child:before{display:none}.org-chart li:only-child{padding-top:0}.org-chart li:first-child:before,.org-chart li:last-child:after{border:0}.org-chart li:last-child:before{border-right:1px solid #c7a8ae;border-radius:0 6px 0 0}.org-chart li:first-child:after{border-radius:6px 0 0 0}.org-chart ul ul:before{content:"";position:absolute;top:0;left:50%;border-left:1px solid #c7a8ae;width:0;height:24px}
.org-node{width:168px;min-height:80px;background:#fff;border:1px solid #e3c1c7;border-top:4px solid var(--red);border-radius:12px;padding:10px;display:inline-block;box-shadow:0 5px 14px rgba(96,12,28,.08);cursor:pointer}.org-node:hover{background:#fff6f7;border-color:var(--red)}.org-node b{font-size:13px;display:block;line-height:1.45}.org-node small{font-size:10px;color:var(--muted);display:block;margin-top:5px}.org-node .node-kind{font-size:10px;color:var(--red);margin-top:5px}
.summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:15px 0}.summary-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:14px}.summary-card b{display:block;color:var(--red);font-size:21px}.summary-card span{font-size:12px;color:var(--muted)}
.data-card{display:flex;flex-direction:column;min-height:205px}.card-top{display:flex;justify-content:space-between;gap:10px}.card-type{font-size:11px;color:var(--red);background:var(--light-red);padding:4px 8px;border-radius:999px;height:max-content}.card-sub{font-size:12px;color:#777;margin-bottom:10px}.card-roles{display:grid;gap:6px;margin-top:8px}.role-line{font-size:12px;background:#fafafa;border-left:3px solid #e7a1ad;padding:7px 9px}.card-footer{margin-top:auto;padding-top:12px;border-top:1px solid #eee;display:flex;justify-content:space-between;color:#888;font-size:11px}
.category-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px}.category-card{background:#fff;border:1px solid var(--line);border-radius:13px;padding:13px;cursor:pointer}.category-card:hover{border-color:var(--red)}.category-card b{color:var(--red);font-size:21px;display:block}.category-card span{font-size:11px;color:#666}
.timeline{position:relative;padding-left:26px}.timeline:before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:#efc5cc}.time-card{position:relative;background:#fff;border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--shadow);cursor:pointer}.time-card:before{content:"";position:absolute;left:-25px;top:19px;width:12px;height:12px;border:3px solid var(--red);background:#fff;border-radius:50%}.time-date{color:var(--red);font-size:12px;font-weight:700;margin-bottom:5px}
.pagination{display:flex;justify-content:center;align-items:center;gap:9px;margin-top:18px}.page-no{color:#777;font-size:12px}
.overlay{position:fixed;inset:0;background:rgba(36,8,14,.42);z-index:50;display:none}.overlay.show{display:block}.drawer{position:absolute;right:0;top:0;width:min(560px,94vw);height:100%;background:#fff;padding:24px;overflow:auto;box-shadow:-12px 0 35px rgba(0,0,0,.16)}.modal{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(840px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;padding:24px}.close{float:right}
.detail-page{display:none}.detail-page.show{display:block}.detail-hero{background:linear-gradient(135deg,var(--red),var(--red2));color:#fff;border-radius:20px;padding:25px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-hero h2{margin:0 0 10px;font-size:26px}.detail-hero p{margin:0;line-height:1.7;color:#ffe8ed}.detail-tags{margin-top:13px}.detail-tags .chip{background:rgba(255,255,255,.16);color:#fff}
.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:16px}.detail-section{background:#fff;border:1px solid var(--line);border-radius:16px;padding:18px;margin-bottom:16px;box-shadow:var(--shadow)}.detail-section h3{margin:0 0 14px;font-size:16px;display:flex;align-items:center}.detail-section h3:before{content:"";width:4px;height:17px;background:var(--red);border-radius:5px;margin-right:8px}.info-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.info-item{background:#fafafa;border-radius:10px;padding:11px}.info-item label{display:block;font-size:11px;color:#888;margin-bottom:5px}.info-item div{font-size:13px;line-height:1.55}.text-list{display:grid;gap:9px}.text-item{padding:10px 12px;border-left:4px solid #e5a6b1;background:#fff8f9;border-radius:7px;font-size:13px;line-height:1.6}
.mini-card-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.mini-card{border:1px solid #eee;border-radius:11px;padding:12px;background:#fff}.mini-card b{display:block;margin-bottom:6px}.source-line{padding:9px 0;border-bottom:1px dashed #ddd;font-size:12px}.warning-box{background:#fff7e8;border:1px solid #f1d29e;border-radius:11px;padding:13px;color:#7a4d00;font-size:12px;line-height:1.65}.safe-box{background:#f7f7f7;border:1px solid #ddd;border-radius:11px;padding:13px;color:#555;font-size:12px;line-height:1.65}
.query-row{display:grid;grid-template-columns:1fr 120px 1fr 42px;gap:8px;margin-bottom:8px}.query-row select,.query-row input{height:37px;border:1px solid #ddd;border-radius:8px;padding:0 9px}.empty{text-align:center;padding:50px;color:#888}
@media(max-width:1150px){.entry-grid,.card-grid,.region-grid{grid-template-columns:repeat(2,1fr)}.category-grid{grid-template-columns:repeat(3,1fr)}.detail-grid{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
@media(max-width:720px){.header-top{padding:10px 14px;height:auto}.header-state{display:none}.main{padding:14px}.hero-mark{display:none}.hero h2{font-size:21px}.metrics,.grid2,.entry-grid,.card-grid,.region-grid,.summary-strip,.category-grid,.info-grid,.mini-card-grid{grid-template-columns:1fr}.toolbar input{min-width:100%}.query-row{grid-template-columns:1fr}.org-chart-wrap{padding:15px}.org-node{width:145px}}
/* ── 页面 JSON 编辑器 ── */
.editor-fab{position:fixed;right:18px;bottom:26px;z-index:40;background:var(--red);color:#fff;border:0;border-radius:50%;width:46px;height:46px;display:grid;place-items:center;cursor:pointer;box-shadow:0 6px 18px rgba(143,16,36,.32)}.editor-fab:hover{background:var(--red2);transform:rotate(30deg)}.editor-fab svg{width:22px;height:22px;display:block}
.editor-overlay{z-index:60}
.editor-modal{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(1080px,94vw);height:min(680px,88vh);background:#fff;border-radius:18px;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 60px rgba(36,8,14,.3)}
.editor-head{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)}.editor-head h2{margin:0;font-size:16px;flex:1}.editor-head .editor-note{font-size:12px;color:var(--muted);margin-right:8px}
.editor-body{display:flex;flex:1;min-height:0}
.editor-tabs{width:210px;border-right:1px solid var(--line);overflow:auto;padding:10px 8px;display:flex;flex-direction:column;gap:4px}
.editor-tab{border:0;background:transparent;text-align:left;padding:9px 11px;border-radius:9px;cursor:pointer;color:#555}.editor-tab b{display:block;font-size:13px}.editor-tab small{font-size:11px;color:#999}.editor-tab:hover{background:var(--light-red)}.editor-tab.active{background:var(--light-red);color:var(--red)}
.editor-pane{flex:1;display:flex;flex-direction:column;min-width:0;padding:14px 16px;gap:10px}
.editor-title-row{display:flex;align-items:center;justify-content:space-between}.editor-title-row b{font-size:14px}
.json-status{font-size:12px;padding:3px 10px;border-radius:999px}.json-status.valid{background:#edf9f2;color:var(--green)}.json-status.invalid{background:var(--light-red);color:var(--red)}
.json-editor{flex:1;width:100%;resize:none;border:1px solid var(--line);border-radius:12px;padding:13px;font:13px/1.6 ui-monospace,Consolas,"Courier New",monospace;color:#333;background:#fafafa;outline:0;tab-size:2}.json-editor:focus{border-color:var(--red);box-shadow:0 0 0 3px rgba(200,16,46,.1)}
.editor-actions{display:flex;gap:8px;flex-wrap:wrap}
.editor-hint{margin:0;font-size:11px;color:#999}
@media(max-width:720px){.editor-modal{width:96vw;height:92vh}.editor-body{flex-direction:column}.editor-tabs{width:auto;flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line)}.editor-tab{white-space:nowrap}}
</style>
</head>
<body>
<header class="header">
<div class="header-top">
<div class="logo">防</div>
<div class="title"><h1>台湾滨海防卫知识数据展示系统</h1><p>红白业务版 · 组织架构与单位档案已合并</p></div>
<div class="header-state">知识库版本 2.0<br>更新日期 2026-06-23</div>
</div>
<nav class="tabs" id="tabs"></nav>
</header>
<main class="main">
<section id="page"></section>
<section id="detailPage" class="detail-page"></section>
</main>
<div id="drawerOverlay" class="overlay"><aside class="drawer"><button class="btn close" onclick="closeDrawer()">关闭</button><div id="drawer"></div></aside></div>
<div id="modalOverlay" class="overlay"><div class="modal"><button class="btn close" onclick="closeModal()">关闭</button><div id="modal"></div></div></div>
<button type="button" id="openEditor" class="editor-fab" aria-label="JSON 编辑器" title="页面 JSON 编辑器"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg></button>
<div id="editorOverlay" class="overlay editor-overlay">
<div class="editor-modal">
<header class="editor-head"><h2>页面 JSON 编辑器</h2><span class="editor-note">按模块编辑,应用后即时重渲染;修改保存在浏览器本地,不回写服务器</span><button type="button" class="btn" id="closeEditor">关闭</button></header>
<div class="editor-body">
<nav id="editorTabs" class="editor-tabs"></nav>
<div class="editor-pane">
<div class="editor-title-row"><b id="editorModuleTitle"></b><span id="jsonStatus" class="json-status valid">JSON 有效</span></div>
<textarea id="jsonEditor" class="json-editor" spellcheck="false" aria-label="当前模块 JSON"></textarea>
<div class="editor-actions"><button type="button" class="btn btn-red" id="applyJson">应用到页面</button><button type="button" class="btn" id="resetJson">恢复默认数据</button><button type="button" class="btn" id="copyJson">复制完整 JSON</button><button type="button" class="btn" id="downloadJson">下载 JSON</button></div>
<p class="editor-hint">模块 = 页面数据(DATA)的顶层键,另附「系统设置」编辑 MANIFEST;沙箱预览(iframe)无法使用本地缓存时,修改仅当次会话有效。</p>
</div>
</div>
</div>
</div>
<script type="application/json" id="page-data">__PAGE_DATA__</script>
<script type="application/json" id="page-manifest">__PAGE_MANIFEST__</script>
<script>__PAGE_RUNTIME__</script>
</body>
</html>
@@ -139,12 +139,8 @@ async def test_html_edit_invalidates_metadata_content_and_list(cached_request, m
request.state.user.system_role = "admin" request.state.user.system_role = "admin"
state = {"html": "old"} state = {"html": "old"}
monkeypatch.setattr(html_templates.template_store, "read_template_html", lambda _id: state["html"]) monkeypatch.setattr(html_templates.template_store, "read_template_html", lambda _id: state["html"])
# content 端点默认走引擎的「完整文档」读取(结构化模板会组合默认数据)。
monkeypatch.setattr(html_templates.template_fill, "read_template_text", lambda _id: state["html"])
monkeypatch.setattr(html_templates.template_store, "list_templates", lambda: [{"name": state["html"]}]) monkeypatch.setattr(html_templates.template_store, "list_templates", lambda: [{"name": state["html"]}])
monkeypatch.setattr( monkeypatch.setattr(html_templates.template_store, "get_template_meta", lambda _id: {"name": state["html"]})
html_templates.template_store, "get_template_meta", lambda _id: {"id": _id, "name": state["html"]}
)
def update(_id, **kwargs): def update(_id, **kwargs):
state["html"] = kwargs["html"] state["html"] = kwargs["html"]
@@ -1,597 +0,0 @@
"""结构化 HTML 模板(kind=structured,先拿 no.1 落地)的引擎/存储/路由测试。
覆盖:
* seed/meta:结构化 no1 四件套正确入库,单文件模板(如 no2)不受影响;
* 契约语义与单文件时代等价:``reference_data`` 读 data.json 与旧 ``const DATA=`` 行
解析结果一致,``validate_data`` / ``merge_with_reference`` 行为不变;
* 组合:``read_template_text`` / ``fill_template`` 产出无残留标记的自包含文档,
数据注入可被页面 ``JSON.parse`` 还原,标题/副标题/更新时间生效;
* data.json 持久化(管理页「编辑数据」):整体替换 + 契约校验(顶层键不可增删);
* 骨架编辑标记校验、结构化重置;
* 旧单文件存储副本的自动升级:未手改 → 升级;手改过 → 保留旧版;
* 路由:content 的 doc/raw 双形态、data 的 GET/PUT 与权限。
"""
from __future__ import annotations
import json
import re
from types import SimpleNamespace
from typing import Any
import pytest
from fastapi import HTTPException
from test_api_read_cache import FakeRedis, _config
from app.gateway import api_read_cache
from app.gateway.routers import html_templates
from deerflow.runtime.scheduler import template_fill, template_store
# no1 是当前唯一的结构化模板。
TPL = "no1"
LEGACY_FILE = template_fill._TEMPLATES_ROOT / TPL / "taiwan_littoral_ui_redwhite_v2.html"
@pytest.fixture
def tmp_store(monkeypatch, tmp_path):
"""隔离的运行时存储:全部用例不触碰真实 .deer-flow 目录。"""
root = tmp_path / "html-templates"
monkeypatch.setattr(template_store, "store_root", lambda: root)
template_fill.invalidate_cache()
template_store.ensure_seeded()
yield root
template_fill.invalidate_cache()
@pytest.fixture
def api_request(monkeypatch, tmp_store):
"""可直接调路由函数的最小 request(admin 身份,目录缓存走 FakeRedis)。"""
redis = FakeRedis()
monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None)
monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None)
monkeypatch.setattr(api_read_cache, "_client", lambda *a, **k: (redis, "test:catalog"))
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(config=_config())),
state=SimpleNamespace(user=SimpleNamespace(id="admin-1", system_role="admin")),
)
async def current_user(req):
return req.state.user
monkeypatch.setattr(html_templates, "get_optional_user_from_request", current_user)
return request
def _page_data_element(html: str) -> dict:
match = re.search(
r'<script type="application/json" id="page-data">(.*?)</script>', html, re.DOTALL
)
assert match is not None, "组合产物缺少 page-data JSON 元素"
return json.loads(match.group(1))
# ── seed / meta ──────────────────────────────────────────────────────────────
def test_structured_seed_and_single_untouched(tmp_store):
meta = template_store.get_template_meta(TPL)
assert meta is not None
assert meta["kind"] == "structured"
assert meta["convertible"] is True
assert meta["manifest_var"] == "MANIFEST"
assert sorted(p.name for p in (tmp_store / TPL).iterdir()) == [
"data.json",
"fields.json",
"manifest.json",
"meta.json",
"runtime.js",
"template.html",
]
# 单文件模板照旧(kind=single,正文 = 完整文档,无结构化部件)。
no2 = template_store.get_template_meta("no2")
assert no2 is not None and no2["kind"] == "single"
assert not (tmp_store / "no2" / "runtime.js").exists()
def test_reference_data_matches_legacy_const_line(tmp_store):
"""data.json 的契约语义必须与单文件时代的 const DATA= 行完全一致。"""
legacy = template_fill._extract_const_object(LEGACY_FILE.read_text(encoding="utf-8"), "DATA")
ref = template_fill.reference_data(TPL)
assert ref == legacy
assert template_fill.validate_data(TPL, legacy) == []
assert set(template_fill.top_level_keys(TPL)) == set(legacy.keys())
# ── 组合 / 填充 ─────────────────────────────────────────────────────────────
def test_read_template_text_composes_default_document(tmp_store):
html = template_fill.read_template_text(TPL)
for marker in template_fill.STRUCTURED_MARKERS:
assert marker not in html
assert 'id="page-data"' in html and 'id="page-manifest"' in html
assert "const DATA=JSON.parse" in html
# 页面内 JSON 编辑器随 runtime 内联。
assert 'id="editorOverlay"' in html and 'id="applyJson"' in html
assert _page_data_element(html)["overview"] == template_fill.reference_data(TPL)["overview"]
def test_fill_template_structured_injects_data_titles_and_date(tmp_store):
ref = template_fill.reference_data(TPL)
modified = json.loads(json.dumps(ref))
modified["overview"] = {
"核心指标": [{"名称": "测试指标", "数值": 7, "单位": "个", "说明": "单元测试"}]
}
import datetime as dt
html = template_fill.fill_template(
TPL,
modified,
update_date=dt.date(2026, 11, 4),
title="新标题",
subtitle="新副标题",
)
for marker in template_fill.STRUCTURED_MARKERS:
assert marker not in html
assert "<title>新标题</title>" in html
assert "<h1>新标题</h1>" in html
assert "新副标题" in html
# MANIFEST 系统.更新时间/名称 同步。
assert "2026-11-04" in html
assert '"名称": "新标题"' in html
# 注入的数据可被页面 JSON.parse 原样还原。
assert _page_data_element(html)["overview"]["核心指标"][0]["名称"] == "测试指标"
def test_merge_and_validate_semantics_unchanged(tmp_store):
# 顶层键缺失 → 硬校验报错(与单文件时代一致)。
errors = template_fill.validate_data(TPL, {"overview": {}})
assert any("缺少顶层字段" in e for e in errors)
# 空内容分支回退模板默认数据。
merged = template_fill.merge_with_reference(TPL, {"overview": {}, "regions": []})
assert merged == template_fill.reference_data(TPL)
# 有内容的分支保留转换结果。
merged2 = template_fill.merge_with_reference(
TPL, {"overview": {"核心指标": [{"名称": "x", "数值": 1, "单位": "", "说明": ""}]}}
)
assert merged2["overview"]["核心指标"][0]["名称"] == "x"
assert merged2["regions"] == template_fill.reference_data(TPL)["regions"]
# ── data.json 持久化 / 骨架编辑 / 重置 ──────────────────────────────────────
def test_write_read_template_data_roundtrip(tmp_store):
ref = template_fill.reference_data(TPL)
modified = json.loads(json.dumps(ref))
modified["overview"]["核心指标"][0]["名称"] = "持久化指标"
meta = template_store.write_template_data(TPL, modified)
assert meta["kind"] == "structured"
assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] == "持久化指标"
# 引擎缓存已失效:reference_data / 组合产物立即反映新数据。
assert template_fill.reference_data(TPL)["overview"]["核心指标"][0]["名称"] == "持久化指标"
assert "持久化指标" in template_fill.read_template_text(TPL)
def test_write_template_manifest_persists_and_composes(tmp_store):
"""MANIFEST 持久化(系统名称/导航页面/交互规则):写回 manifest.json 并进入组合产物。"""
ref = template_fill.reference_data(TPL)
manifest = template_store.read_template_manifest(TPL)
assert manifest.get("系统", {}).get("名称") == "台湾滨海防卫知识数据展示系统"
manifest["系统"]["名称"] = "改名后的系统"
manifest["页面"][0]["名称"] = "首页"
template_store.write_template_data(TPL, ref, manifest=manifest)
assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "改名后的系统"
# 组合产物(MANIFEST 内嵌 JSON)反映新值;导航按钮由页面 JS 依据 MANIFEST 渲染。
composed = template_fill.read_template_text(TPL)
assert "改名后的系统" in composed
assert '"名称": "首页"' in composed
# 非对象 MANIFEST 拒绝。
with pytest.raises(template_store.TemplateStoreError, match="MANIFEST"):
template_store.write_template_data(TPL, ref, manifest=["nope"])
# 重置回随包原版。
template_store.reset_template(TPL)
assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "台湾滨海防卫知识数据展示系统"
def test_write_template_data_rejects_contract_changes(tmp_store):
with pytest.raises(template_store.TemplateStoreError, match="契约"):
template_store.write_template_data(TPL, {"overview": {}})
with pytest.raises(template_store.TemplateStoreError):
template_store.write_template_data(TPL, ["not", "an", "object"])
# 单文件模板不支持数据编辑。
with pytest.raises(template_store.TemplateStoreError, match="结构化"):
template_store.write_template_data("no2", {"x": 1})
def test_update_template_skeleton_marker_guard(tmp_store):
with pytest.raises(template_store.TemplateStoreError, match="注入标记"):
template_store.update_template(TPL, html="<!doctype html><p>标记丢了</p>")
# 保留标记的骨架编辑可以通过,且不影响 kind/convertible。
skeleton = template_store.read_template_html(TPL)
edited = skeleton.replace("JSON 编辑器", "JSON 编辑器·编辑", 1)
meta = template_store.update_template(TPL, html=edited)
assert meta["kind"] == "structured" and meta["convertible"] is True
assert template_fill.read_template_text(TPL).count("JSON 编辑器·编辑") == 1
def test_reset_structured_restores_all_parts(tmp_store):
template_store.write_template_data(
TPL, _with_first_metric_name("持久化指标")
)
template_store.update_template(TPL, html=template_store.read_template_html(TPL) + "\n<!-- edited -->")
template_store.reset_template(TPL)
assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] != "持久化指标"
assert "<!-- edited -->" not in template_store.read_template_html(TPL)
assert template_fill.read_template_text(TPL) # 组合仍可用
def _with_first_metric_name(name: str) -> dict:
ref = template_fill.reference_data(TPL)
modified = json.loads(json.dumps(ref))
modified["overview"]["核心指标"][0]["名称"] = name
return modified
# ── 旧单文件存储副本的自动升级 ─────────────────────────────────────────────
def _make_legacy_copy(store_root, *, modified: bool) -> None:
import shutil
tpl_dir = store_root / TPL
if tpl_dir.exists():
shutil.rmtree(tpl_dir)
tpl_dir.mkdir(parents=True)
text = LEGACY_FILE.read_text(encoding="utf-8")
if modified:
text += "\n<!-- hand edit -->"
(tpl_dir / "template.html").write_text(text, encoding="utf-8")
(tpl_dir / "meta.json").write_text(
json.dumps({"id": TPL, "name": "no.1", "builtin": True, "convertible": True}),
encoding="utf-8",
)
def test_auto_upgrade_untouched_legacy_copy(tmp_store):
_make_legacy_copy(tmp_store, modified=False)
assert template_store.ensure_seeded() is True
meta = template_store.get_template_meta(TPL)
assert meta["kind"] == "structured"
assert (tmp_store / TPL / "runtime.js").is_file()
# 升级后的模板可正常组合。
assert "__PAGE_" not in template_fill.read_template_text(TPL)
def test_auto_upgrade_skips_hand_modified_copy(tmp_store):
_make_legacy_copy(tmp_store, modified=True)
template_store.ensure_seeded()
meta = template_store.get_template_meta(TPL)
assert meta["kind"] == "single"
assert "<!-- hand edit -->" in (tmp_store / TPL / "template.html").read_text(encoding="utf-8")
# 管理页「重置」可手动升级到结构化版。
template_store.reset_template(TPL)
assert template_store.get_template_meta(TPL)["kind"] == "structured"
assert (tmp_store / TPL / "runtime.js").is_file()
# ── 路由 ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_router_content_doc_and_raw(tmp_store, api_request):
doc = await html_templates.get_html_template_content(TPL, api_request)
assert "__PAGE_DATA__" not in doc["html"]
assert 'id="page-data"' in doc["html"]
raw = await html_templates.get_html_template_content(TPL, api_request, raw=True)
assert "__PAGE_DATA__" in raw["html"] and "__PAGE_RUNTIME__" in raw["html"]
@pytest.mark.asyncio
async def test_router_data_get_put_and_guards(tmp_store, api_request):
got = await html_templates.get_html_template_data(TPL, api_request)
assert "overview" in got["data"]
# MANIFEST 随 GET 返回(管理页「系统设置」模块)。
assert got["manifest"]["系统"]["名称"] == "台湾滨海防卫知识数据展示系统"
# 模块中文名(按 MANIFEST.页面 解析,管理页「编辑数据」的模块导航用)。
labels = {m["key"]: m["label"] for m in got["modules"]}
assert labels["overview"] == "总览"
assert labels["regions"] == "沿岸区域"
assert labels["organization_charts"] == "组织与单位"
assert labels["events"] == "演习与项目"
assert set(labels) == set(got["data"].keys())
payload = got["data"]
payload["overview"]["核心指标"][0]["名称"] = "路由持久化"
manifest = json.loads(json.dumps(got["manifest"]))
manifest["系统"]["名称"] = "路由改名系统"
meta = await html_templates.update_html_template_data(
TPL,
html_templates.TemplateDataUpdateRequest(data=payload, manifest=manifest),
api_request,
)
assert meta["kind"] == "structured"
assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] == "路由持久化"
assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统"
# 仅传 data(未动 MANIFEST)时不回写 manifest.json。
await html_templates.update_html_template_data(
TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request
)
assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统"
# 契约被破坏(删顶层键) → 400。
broken = {k: v for k, v in payload.items() if k != "regions"}
with pytest.raises(HTTPException) as exc:
await html_templates.update_html_template_data(
TPL, html_templates.TemplateDataUpdateRequest(data=broken), api_request
)
assert exc.value.status_code == 400
# 单文件模板 → 400。
with pytest.raises(HTTPException) as exc2:
await html_templates.get_html_template_data("no2", api_request)
assert exc2.value.status_code == 400
# 非管理员不可写。
api_request.state.user = SimpleNamespace(id="u2", system_role="user")
with pytest.raises(HTTPException) as exc3:
await html_templates.update_html_template_data(
TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request
)
assert exc3.value.status_code == 403
@pytest.mark.asyncio
async def test_scheduler_fill_path_yields_selfcontained_html(tmp_store):
"""调度器模板填充路径的最终产物自包含:单文件、无标记、数据可解析。"""
data_obj = template_fill.merge_with_reference(TPL, {"overview": _with_first_metric_name("调度指标")["overview"]})
html = template_fill.fill_template(TPL, data_obj, update_date=__import__("datetime").date(2026, 1, 1))
assert html.startswith("<!doctype html>") and "__PAGE_" not in html
assert "调度指标" in html
assert _page_data_element(html)["overview"]["核心指标"][0]["名称"] == "调度指标"
# ── 字段显示 / 自定义模块 ───────────────────────────────────────────────────
def test_field_catalog_and_visibility_roundtrip(tmp_store):
catalog = template_store.read_template_fields(TPL)
labels = {f["label"] for f in catalog}
assert "节点类别" in labels and "来源" in labels
assert all(f.get("description") for f in catalog)
# 隐藏两个字段 → manifest 记 {label: false},其余全部显示。
template_store.write_template_field_visibility(TPL, ["节点类别", "来源"])
manifest = template_store.read_template_manifest(TPL)
assert manifest["字段显示"] == {"节点类别": False, "来源": False}
# 组合产物带隐藏配置,runtime 的 F() 据此不渲染。
composed = template_fill.read_template_text(TPL)
assert '"字段显示"' in composed
# 目录外字段拒绝。
with pytest.raises(template_store.TemplateStoreError, match="未知字段"):
template_store.write_template_field_visibility(TPL, ["不存在的字段"])
# 置空 = 全部显示。
template_store.write_template_field_visibility(TPL, [])
assert template_store.read_template_manifest(TPL)["字段显示"] == {}
def test_custom_modules_write_and_sanitize(tmp_store):
raw = (
'<div class="panel"><h3 onclick="evil()">x</h3><script>alert(1)</script>'
'<a href="javascript:alert(2)">链接</a><p>正常内容</p></div>'
)
template_store.write_template_custom_modules(
TPL, [{"编号": "c1", "页面": "overview", "标题": "测试模块", "html": raw}]
)
saved = template_store.read_template_manifest(TPL)["自定义模块"]
assert len(saved) == 1
html = saved[0]["html"]
assert "正常内容" in html
assert "<script" not in html and "onclick" not in html and "javascript:" not in html
# 组合产物包含模块内容与渲染函数。
composed = template_fill.read_template_text(TPL)
assert "正常内容" in composed and "renderCustomModules" in composed
# 结构校验:缺页面/缺 html 拒绝。
with pytest.raises(template_store.TemplateStoreError, match="页面"):
template_store.write_template_custom_modules(TPL, [{"html": "<div>x</div>"}])
with pytest.raises(template_store.TemplateStoreError, match="html"):
template_store.write_template_custom_modules(TPL, [{"页面": "overview"}])
def test_field_extract_locks_and_restore(tmp_store):
"""字段抽取锁定:锁定字段的键在抽取结果合并后恢复模板现有值(按条目身份匹配)。"""
ref = template_fill.reference_data(TPL)
# 锁定「记录类别」(org_units 条目的 记录类别 键)与「日期」(events 条目的 日期显示 键)。
template_store.write_template_field_locks(TPL, ["记录类别", "日期"])
keys = template_store.locked_field_keys(TPL)
assert "记录类别" in keys and "日期显示" in keys
# 模拟抽取结果:改了第一个单位的记录类别、事件日期,并保留身份键(id/名称)。
extracted = json.loads(json.dumps(ref))
unit = extracted["org_units"][0]
old_cat = ref["org_units"][0]["记录类别"]
unit["记录类别"] = "被抽取改过的类别"
event = extracted["events"][0]
event["日期显示"] = "2026-01-01"
event["名称"] = ref["events"][0].get("名称") or event.get("名称") or event.get("name") or "同身份"
restored = template_store.restore_locked_fields(TPL, extracted)
# 锁定键恢复旧值。
assert restored["org_units"][0]["记录类别"] == old_cat
assert restored["events"][0]["日期显示"] == ref["events"][0]["日期显示"]
# 未锁定分支不动。
assert restored["overview"] == extracted["overview"]
# 目录外字段拒绝。
with pytest.raises(template_store.TemplateStoreError, match="未知字段"):
template_store.write_template_field_locks(TPL, ["不存在字段"])
# 清空 = 全部接受抽取。
template_store.write_template_field_locks(TPL, [])
assert template_store.restore_locked_fields(TPL, extracted) == extracted
@pytest.mark.asyncio
async def test_router_fields_extract_flags(tmp_store, api_request):
got = await html_templates.get_html_template_fields(TPL, api_request)
by_label = {f["label"]: f for f in got["fields"]}
assert by_label["记录类别"]["extract"] is True
assert by_label["记录类别"]["has_key"] is True
assert by_label["组织与单位概况"]["has_key"] is False # 布局分区
meta = await html_templates.update_html_template_fields(
TPL,
html_templates.FieldVisibilityUpdateRequest(hidden=[], extract_locked=["记录类别"]),
api_request,
)
assert meta["kind"] == "structured"
got2 = await html_templates.get_html_template_fields(TPL, api_request)
by2 = {f["label"]: f for f in got2["fields"]}
assert by2["记录类别"]["extract"] is False and by2["日期"]["extract"] is True
@pytest.mark.asyncio
async def test_router_fields_and_custom_modules(tmp_store, api_request):
got = await html_templates.get_html_template_fields(TPL, api_request)
assert any(f["label"] == "节点类别" and f["visible"] for f in got["fields"])
assert [p["key"] for p in got["pages"]][0] == "overview"
assert got["custom_modules"] == []
meta = await html_templates.update_html_template_fields(
TPL, html_templates.FieldVisibilityUpdateRequest(hidden=["节点类别"]), api_request
)
assert meta["kind"] == "structured"
got2 = await html_templates.get_html_template_fields(TPL, api_request)
by_label = {f["label"]: f["visible"] for f in got2["fields"]}
assert by_label["节点类别"] is False and by_label["来源"] is True
await html_templates.update_html_template_custom_modules(
TPL,
html_templates.CustomModuleListUpdateRequest(
modules=[{"页面": "overview", "标题": "t", "html": "<div>hi</div>"}]
),
api_request,
)
got3 = await html_templates.get_html_template_fields(TPL, api_request)
assert got3["custom_modules"][0]["html"] == "<div>hi</div>"
# 非管理员不可写。
api_request.state.user = SimpleNamespace(id="u2", system_role="user")
with pytest.raises(HTTPException) as exc:
await html_templates.update_html_template_fields(
TPL, html_templates.FieldVisibilityUpdateRequest(hidden=[]), api_request
)
assert exc.value.status_code == 403
# ── 定时任务「抽取信息写回模板数据」开关(html_config.template_data_writeback) ──
def _writeback_task(writeback: bool | None) -> dict[str, Any]:
ctx: dict[str, Any] = {"require_html": True, "html_config": {}}
if writeback is not None:
ctx["html_config"]["template_data_writeback"] = writeback
return {
"task_id": "t1",
"user_id": "u1",
"name": "回写联调",
"prompt": "更新总览的核心指标名称为「回写指标」",
"description": "",
"timezone": "UTC",
"enabled": True,
"execution_context": ctx,
}
def _build_writeback_service(monkeypatch, *, converted: dict | None):
"""构造 mock 掉检索/转换/归档的 service,返回 (service, write_mock)。"""
from datetime import UTC
from datetime import datetime as dt
from unittest.mock import AsyncMock, MagicMock
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
from deerflow.runtime.scheduler import service as scheduler_service
from deerflow.runtime.scheduler.service import ScheduledTaskService
service = ScheduledTaskService(app=None, store=MemoryScheduledTaskStore(), poll_interval_seconds=60.0)
ref = template_fill.reference_data(TPL)
source_obj = json.loads(json.dumps(ref))
source_obj["overview"] = {"核心指标": [{"名称": "回写指标", "数值": 3, "单位": "个", "说明": ""}]}
monkeypatch.setattr(
service, "_gather_html_data", AsyncMock(return_value=("一些源数据", []))
)
if converted is None:
async def _fail(*a, **k):
raise scheduler_service.TemplateFillError("boom")
monkeypatch.setattr(service, "_convert_to_template_data", AsyncMock(side_effect=_fail))
else:
# 成功分支返回「抽取了新指标」的转换结果(converted 仅作成功/失败哨兵)。
monkeypatch.setattr(service, "_convert_to_template_data", AsyncMock(return_value=source_obj))
monkeypatch.setattr(service, "_finalize_template_html", AsyncMock(return_value={"ok": True}))
monkeypatch.setattr(service, "_archive_html_index", lambda *_a, **_k: None)
# write_template_data 是同步函数,用 MagicMock 而非 AsyncMock。
write_mock = MagicMock()
monkeypatch.setattr(template_store, "write_template_data", write_mock)
scheduled_for = dt(2026, 11, 4, 12, 0, tzinfo=UTC)
return service, write_mock, scheduled_for, source_obj
@pytest.mark.asyncio
@pytest.mark.parametrize("writeback,converted,expect_write", [
(True, "converted", True),
(False, "converted", False),
(None, "converted", False), # 老任务缺省关闭
(True, None, False), # 转换失败(回退默认数据)不写回
])
async def test_html_template_reference_writeback_switch(tmp_store, monkeypatch, writeback, converted, expect_write):
service, write_mock, scheduled_for, _source = _build_writeback_service(
monkeypatch, converted=converted
)
await service._run_html_template_reference(
_writeback_task(writeback),
tpl_id=TPL,
runtime_thread_id="rt",
scheduled_for=scheduled_for,
run_id="r1",
)
if expect_write:
assert write_mock.call_count == 1
# 只写 data.json:调用参数不含 manifest kwarg(系统名称等 MANIFEST 不动)。
args, kwargs = write_mock.call_args
assert args[0] == TPL and "manifest" not in kwargs
assert args[1]["overview"]["核心指标"][0]["名称"] == "回写指标"
else:
assert write_mock.call_count == 0
@pytest.mark.asyncio
async def test_writeback_respects_field_locks(tmp_store, monkeypatch):
"""锁定字段不接受抽取更新:写回 data.json 的数据里,锁定键保留模板旧值。"""
template_store.write_template_field_locks(TPL, ["日期"])
service, write_mock, scheduled_for, source_obj = _build_writeback_service(
monkeypatch, converted=source_marker()
)
await service._run_html_template_reference(
_writeback_task(True),
tpl_id=TPL,
runtime_thread_id="rt",
scheduled_for=scheduled_for,
run_id="r-lock",
)
assert write_mock.call_count == 1
args, _kwargs = write_mock.call_args
old_dates = [e.get("日期显示") for e in template_fill.reference_data(TPL)["events"]]
new_dates = [e.get("日期显示") for e in args[1]["events"]]
assert new_dates == old_dates # 锁定键恢复旧值
def source_marker():
ref = template_fill.reference_data(TPL)
obj = json.loads(json.dumps(ref))
for e in obj["events"]:
e["日期显示"] = "2099-12-31"
return obj