"use client"; import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; import { languages } from "@codemirror/language-data"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { basicLightInit } from "@uiw/codemirror-theme-basic"; import { monokaiInit } from "@uiw/codemirror-theme-monokai"; import CodeMirror from "@uiw/react-codemirror"; import { BookOpen, Check, ChevronLeft, ChevronRight, Download, ExternalLink, FileText, List, Loader2, Maximize2, Pencil, Trash2, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PointerEvent as ReactPointerEvent } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { HtmlPreviewFrame } from "@/components/html-preview-frame"; import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; import { getBackendBaseURL } from "@/core/config"; import { usePublicBackground } from "@/core/embed/background"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; import { useTheme } from "next-themes"; type PublicTask = { task_id: string; name: string; description?: string | null; schedule_text: string; cron_expr?: string; timezone: string; enabled: boolean; next_run_at?: string | null; last_run_at?: string | null; last_status?: string | null; published?: boolean; }; type PublicRunFile = { index: number; name: string; mime_type?: string | null; kind?: string | null; size?: number | null; /** Server-reported "is the archive file still on disk?" flag. Older responses * may omit this — treat missing as available. */ available?: boolean; }; type PublicRunResult = { title: string; content: string; format: "markdown" | "text" | "json" | "mixed" | "html"; files: PublicRunFile[]; created_at: string; /** Present for ``html_page`` runs: the full generated HTML document. */ html?: string | null; }; type PublicRun = { id: string; task_id: string; scheduled_for: string; started_at?: string | null; finished_at?: string | null; status: "pending" | "running" | "succeeded" | "failed" | string; error?: string | null; result?: PublicRunResult | null; retry_count?: number | null; created_at?: string | null; updated_at?: string | null; }; type PublicRunsResponse = { task: PublicTask; total: number; runs: PublicRun[]; }; type PublicFileContent = { index: number; name: string; mime_type?: string | null; kind?: string | null; size?: number | null; previewable: boolean; content?: string | null; }; function publicBaseUrl() { return `${getBackendBaseURL()}/api/public/scheduled-tasks`; } async function fetchJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, init); if (!response.ok) { const body = await response.json().catch(() => ({ detail: "Request failed" })); throw new Error(typeof body.detail === "string" ? body.detail : "Request failed"); } return response.json() as Promise; } async function fetchPublicTaskRuns(taskId: string): Promise { return fetchJson(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`); } async function fetchPublicRun(runId: string): Promise { return fetchJson(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`); } async function fetchPublicRunFile(runId: string, index: number): Promise { return fetchJson( `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`, ); } type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null }; async function fetchPublicRunHtml(runId: string): Promise { return fetchJson(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`); } async function updatePublicRunFile( runId: string, index: number, content: string, ): Promise { await fetchJson( `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }, ); } async function updatePublicRunBody(runId: string, content: string): Promise { await fetchJson( `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/body`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }, ); } async function deletePublicRun(runId: string): Promise { const response = await fetch( `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`, { method: "DELETE" }, ); if (!response.ok && response.status !== 204) { const body = await response.json().catch(() => ({ detail: "Request failed" })); throw new Error(typeof body.detail === "string" ? body.detail : "Request failed"); } } function publicRunFileDownloadUrl(runId: string, index: number): string { return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}/download`; } function statusLabel(status?: string | null) { switch (status) { case "succeeded": return "成功"; case "failed": return "失败"; case "running": return "执行中"; case "pending": return "等待中"; default: return status || "未知"; } } function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" { if (status === "succeeded") return "default"; if (status === "failed") return "destructive"; if (status === "running") return "secondary"; return "outline"; } function formatDate(value?: string | null): string { return value ? new Date(value).toLocaleString() : "暂无"; } /** * Compact "M月D日" badge prefixed to each run title so visually identical * names (the agent often emits the same title every run) become scannable. * Returns empty string for an invalid / missing date so the title falls back * to its bare form instead of rendering "Invalid Date". */ function formatScheduledDatePrefix(value?: string | null): string { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return `${date.getMonth() + 1}月${date.getDate()}日`; } function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean { const name = file.name.toLowerCase(); return ( file.kind === "markdown" || file.mime_type === "text/markdown" || name.endsWith(".md") || name.endsWith(".markdown") ); } function isFileAvailable(file: PublicRunFile): boolean { // Older responses omit `available`; treat that as still available so we // don't accidentally hide content from a backend that hasn't shipped the // flag yet. return file.available !== false; } function stripExtension(name: string): string { if (!name) return name; const dot = name.lastIndexOf("."); if (dot <= 0) return name; return name.slice(0, dot); } /** * Strip a trailing ` - YYYY-MM-DD HH:MM[:SS]` (or `_YYYY-MM-DD ...`) suffix * that the agent appends to run titles. The scheduled-for column is already * displayed separately below the title, so the duplicated date looks like noise. */ function stripTimestampSuffix(title: string): string { if (!title) return title; const cleaned = title.replace( /[\s_]*[-_]?\s*\d{4}-\d{1,2}-\d{1,2}[\sT]+\d{1,2}:\d{2}(?::\d{2})?\s*$/, "", ); return cleaned.trim() || title; } function stripMarkdownToText(markdown: string): string { return markdown .replace(/```[\s\S]*?```/g, " ") .replace(/~~~[\s\S]*?~~~/g, " ") .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") .replace(/^#{1,6}\s+/gm, "") .replace(/^[>\-*+\d.)\s]+/gm, "") .replace(/[`*_~|]/g, "") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); } function getMarkdownExcerpt(markdown: string): string { const text = stripMarkdownToText(markdown); if (!text) return ""; return text.length > 180 ? `${text.slice(0, 180)}...` : text; } function countReadableWords(markdown: string): number { const text = stripMarkdownToText(markdown); if (!text) return 0; const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0; const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0; return cjk + words; } function selectorForId(id: string): string { const css = window.CSS; if (css?.escape) return `#${css.escape(id)}`; return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; } function KnowledgeMarkdownView({ content, title, subtitle, className, }: { content: string; title?: string; subtitle?: string; className?: string; isLoading?: boolean; remarkPlugins?: unknown; rehypePlugins?: unknown; }) { const rootRef = useRef(null); const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); const headings = useMemo( () => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3), [anchorResolver], ); const [activeHeadingId, setActiveHeadingId] = useState(null); const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]); const sectionCount = headings.length; const wordCount = useMemo(() => countReadableWords(content), [content]); useEffect(() => { setActiveHeadingId(headings[0]?.id ?? null); }, [headings]); useEffect(() => { const root = rootRef.current; if (!root || headings.length === 0) return; const targets = headings .map((item) => root.querySelector(selectorForId(item.id))) .filter((item): item is HTMLElement => !!item); if (targets.length === 0) return; const observer = new IntersectionObserver( (entries) => { const visible = entries .filter((entry) => entry.isIntersecting) .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; if (visible?.target.id) { setActiveHeadingId(visible.target.id); } }, { root: root.closest("[data-radix-scroll-area-viewport]") ?? null, rootMargin: "-96px 0px -65% 0px", threshold: [0, 1], }, ); targets.forEach((target) => observer.observe(target)); return () => observer.disconnect(); }, [headings, content]); const scrollToHeading = useCallback((id: string) => { const root = rootRef.current; const target = root?.querySelector(selectorForId(id)) ?? document.getElementById(id); setActiveHeadingId(id); target?.scrollIntoView({ behavior: "smooth", block: "start" }); }, []); return (
知识梳理 {subtitle && {subtitle}}
{title && (

{title}

)} {excerpt && (

{excerpt}

)}
{sectionCount || 1} 个章节 约 {wordCount} 字
); } const BODY_TAB_VALUE = "__body__"; const HTML_TAB_VALUE = "__html__"; const RUN_LIST_COLLAPSED_WIDTH = 56; const RUN_LIST_DEFAULT_WIDTH = 380; const RUN_LIST_MIN_WIDTH = 280; const RUN_LIST_MAX_WIDTH = 560; /** * Public, unauthenticated scheduled-task viewer. * * URL: `#/public/scheduled-tasks/` * * Left pane lists every run of the task (latest first). Right pane uses tabs * to switch between the inline body (`正文`) and each Markdown attachment. * Markdown attachments take priority — when they exist, the first one is the * default tab. Each tab supports inline editing (saved via the public PUT * endpoints) and a per-tab Word download. */ export default function PublicScheduledTaskPage() { const { taskId } = useParams<{ taskId: string }>(); const [selectedRunId, setSelectedRunId] = useState(null); const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null); const [runListExpanded, setRunListExpanded] = useState(false); const [runListWidth, setRunListWidth] = useState(RUN_LIST_DEFAULT_WIDTH); const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null); const queryClient = useQueryClient(); // 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。 const pageBg = usePublicBackground(); const { data, isLoading, isError, error } = useQuery({ queryKey: ["public-scheduled-task", taskId], queryFn: () => fetchPublicTaskRuns(taskId!), enabled: !!taskId, retry: false, refetchOnWindowFocus: false, refetchInterval: 8000, }); const deleteRunMutation = useMutation({ mutationFn: (runId: string) => deletePublicRun(runId), onSuccess: async (_data, runId) => { toast.success("已删除"); if (selectedRunId === runId) { setSelectedRunId(null); } setPendingDeleteRun(null); await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] }); }, onError: (err) => { toast.error(err instanceof Error ? err.message : "删除失败"); }, }); // Public viewer only surfaces runs that have actual content to show: // pending/running/failed are hidden, and succeeded runs with neither a body // nor a Markdown attachment fall out too (nothing to render). const runs = useMemo(() => { const all = data?.runs ?? []; return all.filter((run) => { if (run.status !== "succeeded") return false; const result = run.result; if (!result) return false; // HTML page runs always have something to render (the iframe preview). const isHtml = result.format === "html" || (result.files ?? []).some( (file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"), ); if (isHtml) return true; const hasBody = !!result.content && result.content.trim().length > 0; const hasMarkdownFile = (result.files ?? []).some( (file) => isMarkdownFile(file) && isFileAvailable(file), ); return hasBody || hasMarkdownFile; }); }, [data]); const selectedRun = useMemo( () => runs.find((r) => r.id === selectedRunId) ?? null, [runs, selectedRunId], ); useEffect(() => { if (!selectedRunId && runs.length > 0) { setSelectedRunId(runs[0]!.id); } if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) { setSelectedRunId(runs[0]!.id); } }, [runs, selectedRunId]); const task = data?.task; const visibleRunListWidth = runListExpanded ? runListWidth : RUN_LIST_COLLAPSED_WIDTH; const expandRunList = useCallback(() => { setRunListExpanded(true); setRunListWidth((width) => Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, width)), ); }, []); const collapseRunList = useCallback(() => { setRunListExpanded(false); }, []); const handleRunListDragStart = useCallback( (event: ReactPointerEvent) => { event.preventDefault(); dragStateRef.current = { startX: event.clientX, startWidth: visibleRunListWidth, }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const handlePointerMove = (moveEvent: PointerEvent) => { const drag = dragStateRef.current; if (!drag) return; const nextWidth = drag.startWidth + moveEvent.clientX - drag.startX; if (nextWidth <= RUN_LIST_COLLAPSED_WIDTH + 28) { setRunListExpanded(false); return; } setRunListExpanded(true); setRunListWidth( Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, nextWidth)), ); }; const handlePointerUp = () => { dragStateRef.current = null; document.body.style.cursor = ""; document.body.style.userSelect = ""; window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointerup", handlePointerUp); }; window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp); }, [visibleRunListWidth], ); if (!taskId) { return (
缺少任务 ID。请使用 /public/scheduled-tasks/<task_id>。
); } if (isLoading) { return (
正在加载任务记录...
); } if (isError) { return (
加载失败:{error instanceof Error ? error.message : "未知错误"}
); } return (
{runListExpanded ? (
{task?.name || "任务"} {task?.name && ( {task.name} )}
{task?.enabled ? "启用" : "暂停"}
{task?.description && task.description.trim() && (

{task.description.trim()}

)}
{runs.length === 0 && (
暂无可查看的执行记录。
)} {runs.map((run) => { const active = run.id === selectedRunId; const runRunning = run.status === "running" || run.status === "pending"; const runTitle = stripTimestampSuffix( run.result?.title || formatDate(run.scheduled_for), ); const datePrefix = formatScheduledDatePrefix(run.scheduled_for); const isDeletingThis = deleteRunMutation.isPending && deleteRunMutation.variables === run.id; return (
setSelectedRunId(run.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedRunId(run.id); } }} >
{datePrefix && ( {datePrefix} )} {runTitle}
{datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
执行时间:{formatDate(run.scheduled_for)}
{runRunning && } {statusLabel(run.status)}
); })}
) : ( )}
{selectedRun ? ( ) : ( 请在左侧选择一条执行记录。 )}
{ if (!open && !deleteRunMutation.isPending) { setPendingDeleteRun(null); } }} > 删除执行记录 确认删除这条执行记录吗?此操作不可恢复。 {pendingDeleteRun && (
{pendingDeleteRun.title}
)}
); } type ContentTab = { /** Stable tab identifier — `__html__` for the HTML preview, `__body__` for * inline content, `file-` for files. */ value: string; /** Short label rendered in the TabsTrigger. */ label: string; /** Distinguishes the HTML preview vs body vs MD attachment so the renderer / * editor knows what to show and which PUT to call. */ kind: "body" | "file" | "html"; /** File index (when `kind === "file"`). */ fileIndex?: number; /** Filename for downloads (defaults to the run title for body). */ fileName?: string; }; type PublicFullscreenPreview = | { mode: "html"; title: string; html: string } | { mode: "markdown"; title: string; content: string } | { mode: "text"; title: string; content: string } | { mode: "file"; title: string; file: PublicFileContent }; function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreenPreview }) { if (preview.mode === "html") { return ; } if (preview.mode === "markdown") { return ( ); } if (preview.mode === "file") { const content = preview.file.content || "无文件内容"; if (isMarkdownFile(preview.file)) { return ( ); } return
{content}
; } return
{preview.content || "无正文内容"}
; } const markdownEditorDarkTheme = monokaiInit({ settings: { background: "transparent", gutterBackground: "transparent", fontSize: "14px", }, }); const markdownEditorLightTheme = basicLightInit({ settings: { background: "transparent", fontSize: "14px", }, }); function PublicMarkdownEditor({ value, onChange, }: { value: string; onChange: (value: string) => void; }) { const { resolvedTheme } = useTheme(); const extensions = useMemo( () => [ markdown({ base: markdownLanguage, codeLanguages: languages, }), ], [], ); return (
); } function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): string { const params = new URLSearchParams(); params.set("tab", tab.kind); if (tab.kind === "file" && tab.fileIndex != null) { params.set("file", String(tab.fileIndex)); } return `#/public/scheduled-tasks/${encodeURIComponent(taskId)}/runs/${encodeURIComponent(runId)}/fullscreen?${params.toString()}`; } /** * Right-pane controller for a single run: builds the tab list (body + MDs), * fetches each tab's source text, and hosts the view/edit toggle. */ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { const queryClient = useQueryClient(); const [fullscreenPreview, setFullscreenPreview] = useState(null); const [runMetaOpen, setRunMetaOpen] = useState(false); // HTML page run: render a sandboxed iframe preview instead of the MD tabs. const isHtmlRun = run.result?.format === "html" || (run.result?.files ?? []).some( (f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"), ); // Prefer the inline copy; fall back to the LONGTEXT-backed /html endpoint for // large pages whose HTML was not inlined into result_json. const { data: htmlData } = useQuery({ queryKey: ["public-scheduled-task", "html", run.id], queryFn: () => fetchPublicRunHtml(run.id), enabled: isHtmlRun && !run.result?.html, refetchOnWindowFocus: false, retry: false, }); const htmlContent = run.result?.html ?? htmlData?.html ?? null; // Skip files whose archive is missing on disk — otherwise the public viewer // would render a tab that 404s with "Result file is no longer available". const markdownFiles = useMemo( () => (run.result?.files ?? []).filter( (file) => isMarkdownFile(file) && isFileAvailable(file), ), [run.result?.files], ); // Tabs: HTML preview first (when present), then every MD file, then the // inline body. For an HTML run that also produced a Markdown file, this keeps // the page preview AND surfaces the .md instead of hiding it. The generated // HTML document itself is never listed as a downloadable "file" tab — it has // its own preview. const tabs: ContentTab[] = useMemo(() => { const list: ContentTab[] = []; if (isHtmlRun) { list.push({ value: HTML_TAB_VALUE, label: "HTML 预览", kind: "html" }); } list.push( ...markdownFiles.map((file, idx) => ({ value: `file-${file.index}`, // Show the actual filename without the `.md` suffix — the user explicitly // asked for "no '文件' label, just the name without extension". label: stripExtension(file.name) || `文件 ${idx + 1}`, kind: "file" as const, fileIndex: file.index, fileName: file.name, })), ); list.push({ value: BODY_TAB_VALUE, label: "正文", kind: "body", fileName: run.result?.title || "scheduled-task-result", }); return list; }, [isHtmlRun, markdownFiles, run.result?.title]); const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? BODY_TAB_VALUE); // When the run's MD-file set shifts (rare) — fall back to the first tab. useEffect(() => { if (!tabs.some((t) => t.value === activeTab)) { setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE); } }, [tabs, activeTab]); // Fetch each MD file's text once. Keyed by file index inside the run. const [fileTexts, setFileTexts] = useState>({}); const [fileLoading, setFileLoading] = useState>({}); const loadFile = useCallback( async (fileIndex: number) => { setFileLoading((s) => ({ ...s, [fileIndex]: true })); try { const file = await fetchPublicRunFile(run.id, fileIndex); const text = file.previewable && file.content != null ? file.content : ""; setFileTexts((s) => ({ ...s, [fileIndex]: text })); } catch { // Race: the archive may have disappeared between list and fetch. Fall // back to empty content; the parent filter will drop this tab on the // next refetch tick. setFileTexts((s) => ({ ...s, [fileIndex]: "" })); } finally { setFileLoading((s) => ({ ...s, [fileIndex]: false })); } }, [run.id], ); // Pull every MD file on first render — keeps tab-switching instant. useEffect(() => { for (const file of markdownFiles) { if (fileTexts[file.index] == null && !fileLoading[file.index]) { void loadFile(file.index); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [markdownFiles, run.id]); // Editor state — separate from displayed source so users can cancel. const [isEditing, setIsEditing] = useState(false); const [draft, setDraft] = useState(""); const activeTabMeta = useMemo( () => tabs.find((t) => t.value === activeTab) ?? tabs[0], [tabs, activeTab], ); const activeSource = useMemo(() => { if (!activeTabMeta) return ""; if (activeTabMeta.kind === "body") return run.result?.content ?? ""; return fileTexts[activeTabMeta.fileIndex!] ?? ""; }, [activeTabMeta, run.result?.content, fileTexts]); // Reset the editor state any time the tab changes underneath us. useEffect(() => { setIsEditing(false); setDraft(activeSource); }, [activeTabMeta?.value, activeSource]); const saveMutation = useMutation({ mutationFn: async () => { if (!activeTabMeta) return; if (activeTabMeta.kind === "body") { await updatePublicRunBody(run.id, draft); } else { await updatePublicRunFile(run.id, activeTabMeta.fileIndex!, draft); } }, onSuccess: async () => { if (activeTabMeta?.kind === "file") { setFileTexts((s) => ({ ...s, [activeTabMeta.fileIndex!]: draft })); } toast.success("已保存"); setIsEditing(false); // Force a refetch so the left-list run row picks up the change too. await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] }); }, onError: (err) => { toast.error(err instanceof Error ? err.message : "保存失败"); }, }); const handleDownloadWord = useCallback(async () => { if (!activeTabMeta) return; const markdown = isEditing ? draft : activeSource; if (!markdown.trim()) { toast.error("当前内容为空"); return; } const fileName = activeTabMeta.fileName || run.result?.title || "scheduled-task-result"; const title = activeTabMeta.kind === "body" ? run.result?.title : undefined; try { await downloadMarkdownAsDocx(markdown, fileName, title); toast.success("Word 文件已生成"); } catch (err) { toast.error(err instanceof Error ? err.message : "生成 Word 失败"); } }, [activeTabMeta, draft, isEditing, activeSource, run.result?.title]); const isFileTab = activeTabMeta?.kind === "file"; const fileIndex = activeTabMeta?.fileIndex; const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false; const showEmptyHint = !activeSource && !fileIsLoading; const fullscreenHref = activeTabMeta ? publicFullscreenHref(taskId, run.id, activeTabMeta) : ""; const fullscreenDisabled = !activeTabMeta || (activeTabMeta.kind === "html" ? !htmlContent : activeTabMeta.kind === "file" ? fileIsLoading : false); const handleOpenFullscreen = useCallback(() => { if (!activeTabMeta) return; if (activeTabMeta.kind === "html") { if (!htmlContent) return; setFullscreenPreview({ mode: "html", title: run.result?.title || "HTML 预览", html: htmlContent, }); return; } if (activeTabMeta.kind === "body") { setFullscreenPreview({ mode: run.result?.format === "markdown" || run.result?.format === "mixed" ? "markdown" : "text", title: run.result?.title || "正文预览", content: activeSource || "无正文内容", }); return; } const sourceFile = markdownFiles.find((file) => file.index === activeTabMeta.fileIndex); setFullscreenPreview({ mode: "file", title: activeTabMeta.fileName || sourceFile?.name || "文件预览", file: { index: activeTabMeta.fileIndex ?? 0, name: activeTabMeta.fileName || sourceFile?.name || "result.md", mime_type: sourceFile?.mime_type ?? "text/markdown", kind: sourceFile?.kind ?? "markdown", size: sourceFile?.size ?? activeSource.length, previewable: true, content: activeSource, }, }); }, [activeTabMeta, activeSource, htmlContent, markdownFiles, run.result?.format, run.result?.title]); return ( <> !open && setFullscreenPreview(null)}> {fullscreenPreview?.title ?? "预览"} {fullscreenPreview && (
)}
{run.result?.title || "执行记录"} {run.result?.title && ( {run.result.title} )}
计划:{formatDate(run.scheduled_for)}
开始:{formatDate(run.started_at)}
完成:{formatDate(run.finished_at)}

开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}

{!isEditing && activeTabMeta && ( <> )} {isFileTab && fileIndex != null && ( 下载原文件 )} {activeTabMeta?.kind !== "html" && ( )} {activeTabMeta?.kind !== "html" && (isEditing ? ( <> ) : ( ))}
{tabs.length === 0 ? (
无可用内容。
) : ( {tabs.map((tab) => ( {tab.label} ))}
{run.error && (
{run.error}
)} {activeTabMeta?.kind === "html" ? ( htmlContent ? ( ) : (
HTML 页面尚未生成或正在生成中。
) ) : fileIsLoading ? (
正在加载文件内容...
) : isEditing ? ( ) : ( {showEmptyHint ? (
无内容。点击「编辑」开始添加。
) : ( )}
)}
)}
); } export function PublicScheduledTaskFullscreenPage() { const { taskId, runId } = useParams<{ taskId: string; runId: string }>(); // 嵌套方可用 ?bg=/?text= 或 postMessage 覆盖背景/文字色。 const pageBg = usePublicBackground(); const [searchParams] = useSearchParams(); const requestedTab = searchParams.get("tab"); const tabKind: ContentTab["kind"] = requestedTab === "body" || requestedTab === "file" || requestedTab === "html" ? requestedTab : "html"; const fileIndexValue = Number(searchParams.get("file")); const fileIndexValid = Number.isInteger(fileIndexValue) && fileIndexValue >= 0; const { data: run, isLoading, isError, error } = useQuery({ queryKey: ["public-scheduled-task", "run", runId], queryFn: () => fetchPublicRun(runId!), enabled: !!runId, refetchOnWindowFocus: false, retry: false, }); const isHtmlRun = run?.result?.format === "html" || (run?.result?.files ?? []).some( (file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"), ); const { data: htmlData, isLoading: htmlLoading } = useQuery({ queryKey: ["public-scheduled-task", "html", runId], queryFn: () => fetchPublicRunHtml(runId!), enabled: !!runId && tabKind === "html" && isHtmlRun && !run?.result?.html, refetchOnWindowFocus: false, retry: false, }); const { data: fileData, isLoading: fileLoading, isError: fileIsError, error: fileError, } = useQuery({ queryKey: ["public-scheduled-task", "file", runId, fileIndexValue], queryFn: () => fetchPublicRunFile(runId!, fileIndexValue), enabled: !!runId && tabKind === "file" && fileIndexValid, refetchOnWindowFocus: false, retry: false, }); const htmlContent = run?.result?.html ?? htmlData?.html ?? null; const backHref = taskId ? `#/public/scheduled-tasks/${encodeURIComponent(taskId)}` : "#/public/scheduled-tasks"; const pageTitle = tabKind === "file" ? fileData?.name || "文件预览" : tabKind === "body" ? run?.result?.title || "正文预览" : htmlData?.title || run?.result?.title || "HTML 预览"; return (
{pageTitle}
{tabKind === "file" && runId && fileIndexValid && ( )}
{!runId || !taskId ? (
缺少任务或执行记录 ID。
) : isLoading ? (
正在加载预览...
) : isError ? (
加载失败:{error instanceof Error ? error.message : "未知错误"}
) : tabKind === "html" ? ( htmlContent ? ( ) : (
{htmlLoading && } HTML 页面尚未生成或正在生成中。
) ) : tabKind === "file" ? ( fileLoading ? (
正在加载文件...
) : !fileIndexValid || fileIsError || !fileData ? (
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
) : (
) ) : (
)}
); }