feat(定时任务): 内置「模板数据转换器」按 HTML 模板填充 JSON 生成大屏

- 模板填充引擎 template_fill.py:模板注册表 + schema 骨架 + 结构校验 + 行级安全填充;
  模板自带 DATA 即 schema 来源。no.1 可转换填充(含 MANIFEST 更新时间刷新),
  no.2 暂为「只展示」(convertible=False) 原样输出已有数据
- 内置 agent template-json-builder(seed + SOUL),随 lifespan 注入,自动出现在
  定时任务「执行智能体」下拉
- 调度器新增模板填充执行分支 _run_template_fill_attempt:源 JSON→大模型转换→
  结构校验→失败回喂重试;空源/只展示模板直接用模板默认数据,绝不失败
- API:GET /api/scheduled-tasks/html-templates
- 前端:选中该 agent 后条件渲染「HTML模板」下拉 + .json 上传,只展示型模板隐藏 JSON 输入
- 文档同步(前后端 CLAUDE.md)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-23 18:00:00 +08:00
1 parent 1a2ef0a537
commit cd33aaeded
17 files changed
+1511 -57

No files matched your search

+40 -8
View File
@@ -7,7 +7,8 @@
* 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时
* config 进种子,避免写死;一旦保存过,后端即唯一数据源。
*
* 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/
* 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头
* 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/
* ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。
*/
@@ -20,6 +21,9 @@ import {
resolveTaskDeeplinkTarget,
} from "@/core/auth/task-deeplink";
/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */
export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent";
/** 任务工作区业务码(与路由 taskBase 一一对应)。 */
export type TaskBusiness = "rwfx" | "3qfx" | "xdfx";
@@ -94,7 +98,8 @@ export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [
/**
* 代码种子默认按钮(仅在后端整张表为空时使用):
* - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析;
* - rwfx:历史按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+「上一步」+ 切换到三情/行动分析;
* 「上一步」紧邻「下一步」左侧(底部栏 prev 组在 next 组前),默认指向应用内大屏绘制页(?taskId=任务id);
* - 3qfx / xdfx:互相切换到另外两个业务。
* 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。
*/
@@ -147,6 +152,22 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
enabled: true,
sortOrder: 0,
},
{
// 上一步:放在「下一步」左边(底部快捷跳转栏,prev 组渲染在 next 组之前)。
// 默认指向**应用内**「大屏绘制」页 /page/strategy/dashboard-agent(内部路由,以 / 开头),
// 携带 ?taskId=任务id(大屏页按 taskId 取数);openMode=self → 同窗 react-router 导航。
id: "rwfx-prev",
business: "rwfx",
label: "上一步",
linkType: "url",
target: DASHBOARD_AGENT_ROUTE,
appendTaskId: true,
idParam: "taskId",
idKind: "task",
openMode: "self",
enabled: true,
sortOrder: 1,
},
{
id: "rwfx-next",
business: "rwfx",
@@ -158,7 +179,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 1,
sortOrder: 2,
},
{
id: "rwfx-detail3q",
@@ -171,11 +192,11 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] {
idKind: "task",
openMode: "blank",
enabled: true,
sortOrder: 2,
sortOrder: 3,
},
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4),
pz("rwfx-purpose", "rwfx", 5),
sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 4),
sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 5),
pz("rwfx-purpose", "rwfx", 6),
// 3qfx(三情分析):切换到任务/行动分析
sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0),
sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1),
@@ -237,7 +258,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro
const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId;
const param = (btn.idParam || "id").trim() || "id";
const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base;
// 打开方式:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
// 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页):
// self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。
if (base.startsWith("/") && !base.startsWith("//")) {
if (btn.openMode === "blank") {
const full = `${window.location.origin}${window.location.pathname}#${url}`;
window.open(full, "_blank", "noopener,noreferrer");
} else {
ctx.navigate(url);
}
return;
}
// 外链:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。
if (btn.openMode === "self") {
window.location.assign(url);
} else {
@@ -3,6 +3,7 @@ import { getBackendBaseURL } from "@/core/config";
import type {
HtmlPageFavorite,
HtmlTemplateOption,
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
@@ -32,6 +33,14 @@ export async function getSchedulerThread() {
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
}
/** 可选的 HTML 模板清单(模板数据转换器填充用)。 */
export async function listHtmlTemplates() {
const data = await parseResponse<{ templates: HtmlTemplateOption[] }>(
await apiFetch(`${baseUrl()}/html-templates`),
);
return data.templates ?? [];
}
export interface ListScheduledTasksParams {
search?: string;
tagIds?: string[];
@@ -16,6 +16,7 @@ import {
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
listHtmlTemplates,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
getScheduledTaskRunHtml,
@@ -45,6 +46,17 @@ export function useSchedulerThread() {
});
}
/** 可选的 HTML 模板清单(模板数据转换器填充用)。模板是后端静态注册,长缓存即可。 */
export function useHtmlTemplates(enabled = true) {
return useQuery({
queryKey: ["scheduled-tasks", "html-templates"],
queryFn: listHtmlTemplates,
enabled,
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
}
/** A task is currently executing — used to drive live polling of the task list. */
function taskIsLive(status?: string | null): boolean {
return status === "running" || status === "pending";
@@ -24,8 +24,27 @@ export type HtmlPageConfig = {
model_name?: string | null;
};
/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。
* 持久化在 ``execution_context.template_config`` 里(无独立列)。 */
export type TemplateFillConfig = {
template_id?: string | null;
/** 可选:覆盖转换所用模型(留空走 agent.model / 系统默认)。 */
model_name?: string | null;
};
/** ``GET /api/scheduled-tasks/html-templates`` 返回的一条模板选项。 */
export type HtmlTemplateOption = {
id: string;
name: string;
description?: string | null;
/** false = 暂只「直接展示已有数据」,不需要也不会用源 JSON 转换填充。 */
convertible?: boolean;
};
export type ScheduledTaskExecutionContext = {
require_markdown?: boolean;
/** 模板填充模式:选中「模板数据转换器」+ 选模板后填充进固定 HTML 模板。 */
template_config?: TemplateFillConfig;
/**
* The unified "必须生成 HTML" switch. The task form no longer splits "普通任务 /
* HTML 页面" into separate kinds — instead ``require_html`` and
+169 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
@@ -21,6 +21,7 @@ import {
RefreshCw,
TagsIcon,
Trash2,
Upload,
X,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
@@ -54,6 +55,7 @@ import {
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useHtmlTemplates,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
@@ -107,6 +109,9 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
/** 内置「模板数据转换器」agent 的稳定 id(后端 seed)。选中它时表单露出 HTML 模板下拉。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const emptyForm = {
name: "",
description: "",
@@ -114,6 +119,8 @@ const emptyForm = {
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// 模板填充模式:选中「模板数据转换器」后要把源 JSON 填进哪个 HTML 模板。
template_id: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
@@ -172,6 +179,25 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
// 模板填充模式的 .json 上传:读入文件文本填进 prompt 框(不改后端契约)。
const jsonUploadRef = useRef<HTMLInputElement | null>(null);
const handleJsonUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = ""; // 允许重复上传同名文件
if (!file) return;
try {
const text = await file.text();
// 校验是 JSON,给即时反馈;非法也照样填入让用户修正。
try {
JSON.parse(text);
} catch {
toast.error("文件不是合法 JSON,已填入文本框请检查", darkToastOpts.error);
}
setForm((value) => ({ ...value, prompt: text }));
} catch {
toast.error("读取文件失败", darkToastOpts.error);
}
};
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
@@ -285,6 +311,7 @@ export default function ScheduledTasksPage() {
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
template_id: task.execution_context?.template_config?.template_id ?? "",
require_markdown: !!task.execution_context?.require_markdown,
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
@@ -311,7 +338,15 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.require_html;
// 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉,
// 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。
const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID;
const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent);
const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id);
// 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。
const templateDisplayOnly =
isTemplateAgent && selectedTemplate?.convertible === false;
const isHtmlTask = !isTemplateAgent && form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
@@ -322,8 +357,22 @@ export default function ScheduledTasksPage() {
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
// 模板填充模式:只带 template_config,后端据此走「转换 JSON → 填充模板」分支。
if (isTemplateAgent && form.template_id) {
return {
template_config: { template_id: form.template_id },
require_html: false,
task_kind: "agent",
html_config: undefined,
};
}
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
return {
require_html: false,
task_kind: "agent",
html_config: undefined,
template_config: undefined,
};
}
return {
require_html: true,
@@ -1110,46 +1159,126 @@ export default function ScheduledTasksPage() {
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<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">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
{isTemplateAgent && (
<div className="flex flex-col gap-2 rounded-md border border-primary/30 bg-primary/5 p-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">HTML 模板</p>
<p className="text-muted-foreground text-xs">
{templateDisplayOnly ? (
<>选择要展示的 HTML 模板。该模板暂为「直接展示已有数据」,执行时将原样输出其现有页面。</>
) : (
<>
选择要把源数据填充进去的 HTML 模板。执行时智能体会把下方源 JSON 转换成该模板所需的结构,校验后填充生成可预览的页面。
<span className="text-foreground/70">未提供源 JSON 时,将直接展示该模板的默认数据。</span>
</>
)}
</p>
</div>
<Select
value={form.template_id || undefined}
onValueChange={(v) =>
setForm((value) => ({ ...value, template_id: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择 HTML 模板" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
{htmlTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
<span className="block whitespace-normal break-words">
{tpl.name}
{tpl.description ? ` — ${tpl.description}` : ""}
</span>
</SelectItem>
))}
{htmlTemplates.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无可用模板
</div>
)}
</SelectContent>
</Select>
{templateDisplayOnly ? (
<p className="text-muted-foreground text-xs">
该模板暂为「直接展示已有数据」,无需填写源 JSON;执行时将原样输出模板的现有页面。
</p>
) : (
<div className="flex items-center gap-2">
<input
ref={jsonUploadRef}
type="file"
accept=".json,application/json"
className="hidden"
onChange={handleJsonUpload}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => jsonUploadRef.current?.click()}
>
<Upload className="mr-1 size-4" />
上传 .json 文件
</Button>
<span className="text-muted-foreground text-xs">
也可直接在下方粘贴源 JSON(上传会填入下方文本框)
</span>
</div>
)}
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
)}
{!templateDisplayOnly && (
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isTemplateAgent
? "源 JSON 数据(粘贴或上传 .json),将被智能体转换并填充进所选 HTML 模板;留空则展示模板默认数据"
: isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
</div>
<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">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
)}
{!isTemplateAgent && (
<>
<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">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<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">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
</>
)}
{isHtmlTask && (
<>
@@ -10,6 +10,7 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
ExternalLink,
FileSearch,
@@ -95,16 +96,27 @@ export function TaskJumpBar({
});
};
const nextButtons = jumpButtons.filter(isNextTaskButton);
const menuButtons = jumpButtons.filter((btn) => !isNextTaskButton(btn));
// 底部栏只放「上一步 / 下一步」:prev 组渲染在 next 组之前 → 视觉上「上一步」在「下一步」左边
// (容器 justify-end 右对齐,数组先后即左右先后)。其余按钮进右侧悬浮菜单。
const prevButtons = jumpButtons.filter(isPrevTaskButton);
const nextButtons = jumpButtons.filter((btn) => isNextTaskButton(btn) && !isPrevTaskButton(btn));
const barButtons = [...prevButtons, ...nextButtons];
const menuButtons = jumpButtons.filter(
(btn) => !isNextTaskButton(btn) && !isPrevTaskButton(btn),
);
return (
<>
{menuButtons.length > 0 && <TaskSideJumpMenu buttons={menuButtons} onRun={handleRun} />}
{nextButtons.length > 0 && (
{barButtons.length > 0 && (
<div className="border-border/70 bg-background/90 flex shrink-0 flex-wrap items-center justify-end gap-3 border-t px-5 py-3 backdrop-blur-xl dark:border-[#1b477e]/70 dark:bg-[#061b35]/85">
{nextButtons.map((btn) => (
<TaskJumpButton key={btn.id} button={btn} onRun={handleRun} />
{barButtons.map((btn) => (
<TaskJumpButton
key={btn.id}
button={btn}
onRun={handleRun}
variant={isPrevTaskButton(btn) ? "secondary" : "primary"}
/>
))}
</div>
)}
@@ -122,21 +134,35 @@ function isNextTaskButton(btn: TaskButton) {
return btn.id.toLowerCase().includes("next") || btn.label.trim() === "下一步";
}
/** 「上一步」按钮:id 含 prev 或文案为「上一步」——底部栏里渲染到「下一步」左边。 */
function isPrevTaskButton(btn: TaskButton) {
return btn.id.toLowerCase().includes("prev") || btn.label.trim() === "上一步";
}
function TaskJumpButton({
button,
onRun,
variant = "primary",
}: {
button: TaskButton;
onRun: (button: TaskButton) => void;
/** primary=下一步(实心主色)/ secondary=上一步(描边次要,置于下一步左侧)。 */
variant?: "primary" | "secondary";
}) {
const styles =
variant === "secondary"
? "border-[var(--color-main)]/40 bg-transparent text-[var(--color-main)] hover:bg-[var(--color-main)]/10 dark:text-[#7eb6ff] dark:border-[#7eb6ff]/40 dark:hover:bg-[#7eb6ff]/10"
: "border-transparent bg-[var(--color-main)] text-white shadow-sm hover:bg-[var(--brand-950)]";
return (
<Button
className="h-11 gap-2 border-transparent bg-[var(--color-main)] px-6 text-base text-white shadow-sm hover:bg-[var(--brand-950)]"
variant={variant === "secondary" ? "outline" : "default"}
className={cn("h-11 gap-2 px-6 text-base", styles)}
onClick={() => onRun(button)}
title={desensitize(button.label)}
>
{variant === "secondary" && <ArrowLeft className="h-5 w-5 shrink-0" />}
<span className="truncate">{desensitize(button.label)}</span>
{renderTaskJumpIcon(button, "h-5 w-5 shrink-0")}
{variant === "primary" && renderTaskJumpIcon(button, "h-5 w-5 shrink-0")}
</Button>
);
}