"use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Download, FileText, Loader2, Pencil, Trash2, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams } 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 { 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 { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { getBackendBaseURL } from "@/core/config"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; 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"; files: PublicRunFile[]; created_at: string; }; 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 fetchPublicRunFile(runId: string, index: number): Promise { return fetchJson( `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`, ); } 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() : "暂无"; } 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; } const BODY_TAB_VALUE = "__body__"; /** * 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 queryClient = useQueryClient(); 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; 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]); if (!taskId) { return (
缺少任务 ID。请使用 /public/scheduled-tasks/<task_id>。
); } if (isLoading) { return (
正在加载任务记录...
); } if (isError) { return (
加载失败:{error instanceof Error ? error.message : "未知错误"}
); } const task = data?.task; return (
{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 isDeletingThis = deleteRunMutation.isPending && deleteRunMutation.variables === run.id; return (
setSelectedRunId(run.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setSelectedRunId(run.id); } }} >
{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 — `__body__` for inline content, `file-` for files. */ value: string; /** Short label rendered in the TabsTrigger. */ label: string; /** Distinguishes body vs MD attachment so the editor knows which PUT to call. */ kind: "body" | "file"; /** File index (when `kind === "file"`). */ fileIndex?: number; /** Filename for downloads (defaults to the run title for body). */ fileName?: string; }; /** * 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(); // 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: every MD file first (priority per spec), then the inline body. const tabs: ContentTab[] = useMemo(() => { const list: ContentTab[] = 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", fileIndex: file.index, fileName: file.name, })); list.push({ value: BODY_TAB_VALUE, label: "正文", kind: "body", fileName: run.result?.title || "scheduled-task-result", }); return list; }, [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; return ( <>
{run.result?.title || "执行记录"} {run.result?.title && ( {run.result.title} )}

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

{isFileTab && fileIndex != null && ( 下载原文件 )} {isEditing ? ( <> ) : ( )}
{tabs.length === 0 ? (
无可用内容。
) : ( {tabs.map((tab) => ( {tab.label} ))}
{run.error && (
{run.error}
)} {fileIsLoading ? (
正在加载文件内容...
) : isEditing ? (