Files
deerflow-code/frontend-web/src/pages/PublicScheduledTaskPage.tsx
T

1507 lines
55 KiB
TypeScript

"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 { Textarea } from "@/components/ui/textarea";
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";
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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>;
}
async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse> {
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
}
async function fetchPublicRun(runId: string): Promise<PublicRun> {
return fetchJson<PublicRun>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`);
}
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
return fetchJson<PublicFileContent>(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
);
}
type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null };
async function fetchPublicRunHtml(runId: string): Promise<PublicRunHtml> {
return fetchJson<PublicRunHtml>(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`);
}
async function updatePublicRunFile(
runId: string,
index: number,
content: string,
): Promise<void> {
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<void> {
await fetchJson(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/body`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
);
}
async function deletePublicRun(runId: string): Promise<void> {
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<HTMLDivElement>(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<string | null>(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<HTMLElement>(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<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
setActiveHeadingId(id);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
return (
<div
ref={rootRef}
className={cn(
"grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]",
className,
)}
>
<article className="min-w-0 overflow-hidden rounded-lg border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="border-b border-slate-100 bg-slate-50/90 px-6 py-5 dark:border-slate-800 dark:bg-slate-900/70 md:px-8">
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-950 dark:text-slate-300 dark:ring-slate-800">
<BookOpen className="size-3.5" />
知识梳理
</span>
{subtitle && <span className="truncate">{subtitle}</span>}
</div>
{title && (
<h1 className="mt-3 text-xl font-semibold leading-snug text-slate-950 dark:text-slate-50 md:text-2xl">
{title}
</h1>
)}
{excerpt && (
<p className="mt-3 max-w-4xl text-sm leading-7 text-slate-600 dark:text-slate-300">
{excerpt}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
{sectionCount || 1} 个章节
</span>
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
约 {wordCount} 字
</span>
</div>
</div>
<div className="wiki-markdown public-report-markdown mx-auto w-full max-w-[980px] px-6 py-7 md:px-10 md:py-9">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</article>
<aside className="hidden min-w-0 lg:block">
<div className="sticky top-4 rounded-lg border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100">
<List className="size-4" />
内容目录
</div>
{headings.length > 0 ? (
<nav className="max-h-[calc(100vh-210px)] space-y-1 overflow-auto pr-1">
{headings.map((item) => (
<button
key={item.id}
type="button"
className={cn(
"block w-full truncate rounded-md border-l-2 border-transparent px-2 py-1.5 text-left text-xs leading-5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-slate-50",
activeHeadingId === item.id &&
"border-primary bg-primary/10 font-medium text-primary dark:text-primary",
item.level === 2 && "pl-4",
item.level === 3 && "pl-7 text-slate-500 dark:text-slate-400",
)}
title={item.text}
onClick={() => scrollToHeading(item.id)}
>
{item.text}
</button>
))}
</nav>
) : (
<div className="rounded-md border border-dashed p-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
暂无标题目录
</div>
)}
</div>
</aside>
</div>
);
}
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/<task_id>`
*
* 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<string | null>(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<HTMLDivElement>) => {
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 (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm" style={pageBg}>
缺少任务 ID。请使用 <code className="mx-1">/public/scheduled-tasks/&lt;task_id&gt;</code>。
</div>
);
}
if (isLoading) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm" style={pageBg}>
<Loader2 className="size-4 animate-spin" />
正在加载任务记录...
</div>
);
}
if (isError) {
return (
<div className="text-destructive flex h-screen items-center justify-center text-sm" style={pageBg}>
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
);
}
return (
<TooltipProvider delayDuration={150}>
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4" style={pageBg}>
<div
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
style={{ width: visibleRunListWidth }}
>
{runListExpanded ? (
<Card className="flex min-h-0 w-full flex-col overflow-hidden">
<CardHeader className="gap-1">
<div className="flex items-start justify-between gap-2">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
{task?.name || "任务"}
</CardTitle>
</TooltipTrigger>
{task?.name && (
<TooltipContent side="bottom" className="max-w-[320px] break-all">
{task.name}
</TooltipContent>
)}
</Tooltip>
<div className="flex shrink-0 items-center gap-1">
<Badge variant={task?.enabled ? "default" : "secondary"}>
{task?.enabled ? "启用" : "暂停"}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary size-7"
onClick={collapseRunList}
title="收起执行记录列表"
aria-label="收起执行记录列表"
>
<ChevronLeft className="size-4" />
</Button>
</div>
</div>
{task?.description && task.description.trim() && (
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
{task.description.trim()}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-2 p-3 pt-0">
{runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
暂无可查看的执行记录。
</div>
)}
{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 (
<div
key={run.id}
role="button"
tabIndex={0}
className={cn(
"bg-card hover:bg-accent/60 group rounded-md border p-3 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active && "border-primary bg-accent",
)}
onClick={() => setSelectedRunId(run.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setSelectedRunId(run.id);
}
}}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-sm leading-snug font-medium break-all"
title={datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
>
{datePrefix && (
<span className="text-muted-foreground mr-1.5 text-xs font-normal tabular-nums">
{datePrefix}
</span>
)}
{runTitle}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[360px] break-all">
{datePrefix ? `${datePrefix} · ${runTitle}` : runTitle}
</TooltipContent>
</Tooltip>
<div className="text-muted-foreground mt-1 text-xs">
执行时间:{formatDate(run.scheduled_for)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Badge
variant={statusVariant(run.status)}
className={cn(
"shrink-0",
runRunning && "border-primary/30 bg-primary/10 text-primary",
)}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7"
disabled={isDeletingThis}
onClick={(event) => {
event.stopPropagation();
setPendingDeleteRun({ id: run.id, title: runTitle });
}}
title="删除记录"
>
{isDeletingThis ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Trash2 className="size-3.5" />
)}
</Button>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<Card className="flex min-h-0 w-full flex-col items-center overflow-hidden py-3">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-primary"
onClick={expandRunList}
title="展开执行记录列表"
aria-label="展开执行记录列表"
>
<List className="size-4" />
</Button>
</Card>
)}
<div
role="separator"
aria-orientation="vertical"
title="拖拽调整列表宽度"
className="group absolute top-0 -right-3 z-20 flex h-full w-6 cursor-col-resize items-center justify-center"
onPointerDown={handleRunListDragStart}
>
<div className="bg-border group-hover:bg-primary/70 h-full w-px transition-colors" />
<div className="bg-background text-muted-foreground group-hover:text-primary absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm transition-colors">
<ChevronRight className="size-3" />
</div>
</div>
</div>
<Card className="ml-4 flex min-h-0 flex-1 flex-col">
{selectedRun ? (
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
) : (
<CardContent className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
请在左侧选择一条执行记录。
</CardContent>
)}
</Card>
</div>
<Dialog
open={pendingDeleteRun !== null}
onOpenChange={(open) => {
if (!open && !deleteRunMutation.isPending) {
setPendingDeleteRun(null);
}
}}
>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>删除执行记录</DialogTitle>
<DialogDescription>
确认删除这条执行记录吗?此操作不可恢复。
</DialogDescription>
</DialogHeader>
{pendingDeleteRun && (
<div className="bg-muted/40 text-foreground rounded-md border px-3 py-2 text-sm break-all">
{pendingDeleteRun.title}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setPendingDeleteRun(null)}
disabled={deleteRunMutation.isPending}
>
取消
</Button>
<Button
variant="destructive"
onClick={() => {
if (pendingDeleteRun) {
deleteRunMutation.mutate(pendingDeleteRun.id);
}
}}
disabled={deleteRunMutation.isPending}
>
{deleteRunMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}
type ContentTab = {
/** Stable tab identifier — `__html__` for the HTML preview, `__body__` for
* inline content, `file-<index>` 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 <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
}
if (preview.mode === "markdown") {
return (
<ReportMarkdownView
className="max-w-none"
content={preview.content || "无正文内容"}
title={preview.title}
/>
);
}
if (preview.mode === "file") {
const content = preview.file.content || "无文件内容";
if (isMarkdownFile(preview.file)) {
return (
<ReportMarkdownView
className="max-w-none"
content={content}
title={preview.title}
/>
);
}
return <pre className="whitespace-pre-wrap break-words text-sm">{content}</pre>;
}
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
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<PublicFullscreenPreview | null>(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<string>(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<Record<number, string>>({});
const [fileLoading, setFileLoading] = useState<Record<number, boolean>>({});
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<string>("");
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 (
<>
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
<DialogTitle className="truncate text-base">
{fullscreenPreview?.title ?? "预览"}
</DialogTitle>
</DialogHeader>
{fullscreenPreview && (
<div
className={cn(
"min-h-0 flex-1",
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
)}
>
<PublicFullscreenPreviewContent preview={fullscreenPreview} />
</div>
)}
</DialogContent>
</Dialog>
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
{run.result?.title || "执行记录"}
</CardTitle>
</TooltipTrigger>
{run.result?.title && (
<TooltipContent side="bottom" className="max-w-[420px] break-all">
{run.result.title}
</TooltipContent>
)}
</Tooltip>
<Collapsible open={runMetaOpen} onOpenChange={setRunMetaOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
>
<ChevronRight
className={cn("size-3.5 transition-transform", runMetaOpen && "rotate-90")}
/>
运行信息
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 grid gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground sm:grid-cols-3">
<div className="min-w-0 break-all">计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>完成:{formatDate(run.finished_at)}</div>
</div>
</CollapsibleContent>
</Collapsible>
<p className="hidden">
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isEditing && activeTabMeta && (
<>
<Button
size="sm"
variant="outline"
onClick={handleOpenFullscreen}
disabled={fullscreenDisabled}
title="全屏查看"
>
<Maximize2 className="size-4" />
全屏
</Button>
<Button
size="sm"
variant="outline"
onClick={() => window.open(fullscreenHref, "_blank", "noopener,noreferrer")}
disabled={fullscreenDisabled}
title="新页面打开全屏预览"
>
<ExternalLink className="size-4" />
新页面
</Button>
</>
)}
{isFileTab && fileIndex != null && (
<a
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
href={publicRunFileDownloadUrl(run.id, fileIndex)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-4" />
下载原文件
</a>
)}
{activeTabMeta?.kind !== "html" && (
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
)}
{activeTabMeta?.kind !== "html" &&
(isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setDraft(activeSource);
}}
disabled={saveMutation.isPending}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
保存
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setDraft(activeSource);
setIsEditing(true);
}}
disabled={fileIsLoading}
>
<Pencil className="size-4" />
编辑
</Button>
))}
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{tabs.length === 0 ? (
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full min-h-0 flex-col gap-0 px-4 pb-3"
>
<TabsList
variant="line"
className="bg-card sticky top-0 z-10 h-auto flex-wrap justify-start gap-1 border-b py-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-accent/60 max-w-[220px]"
title={tab.label}
>
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex min-h-0 flex-1 flex-col pt-3">
{run.error && (
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
{run.error}
</div>
)}
{activeTabMeta?.kind === "html" ? (
htmlContent ? (
<HtmlPreviewFrame
html={htmlContent}
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)
) : fileIsLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件内容...
</div>
) : isEditing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="min-h-0 flex-1 resize-none font-mono text-sm"
spellCheck={false}
/>
) : (
<ScrollArea className="min-h-0 flex-1">
{showEmptyHint ? (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无内容。点击「编辑」开始添加。
</div>
) : (
<ReportMarkdownView
className="max-w-none pr-2"
content={activeSource}
title={
activeTabMeta?.kind === "file"
? stripExtension(activeTabMeta.fileName || "")
: run.result?.title
}
/>
)}
</ScrollArea>
)}
</div>
</Tabs>
)}
</CardContent>
</>
);
}
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 (
<TooltipProvider delayDuration={150}>
<div className="bg-background flex h-screen min-h-0 flex-col" style={pageBg}>
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button asChild variant="ghost" size="sm" className="h-8 px-2 text-xs">
<a href={backHref}>
<ChevronLeft className="size-3" />
返回列表
</a>
</Button>
<div className="min-w-0 flex-1 truncate text-sm font-medium" title={pageTitle}>
{pageTitle}
</div>
{tabKind === "file" && runId && fileIndexValid && (
<Button asChild variant="outline" size="sm" className="h-8 px-2 text-xs">
<a
href={publicRunFileDownloadUrl(runId, fileIndexValue)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-3" />
下载
</a>
</Button>
)}
</div>
<div className="min-h-0 flex-1">
{!runId || !taskId ? (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
缺少任务或执行记录 ID。
</div>
) : isLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载预览...
</div>
) : isError ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
) : tabKind === "html" ? (
htmlContent ? (
<HtmlPreviewFrame html={htmlContent} className="h-full w-full border-0 bg-white" />
) : (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
{htmlLoading && <Loader2 className="size-4 animate-spin" />}
HTML 页面尚未生成或正在生成中。
</div>
)
) : tabKind === "file" ? (
fileLoading ? (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件...
</div>
) : !fileIndexValid || fileIsError || !fileData ? (
<div className="text-destructive flex h-full items-center justify-center text-sm">
文件加载失败:{fileError instanceof Error ? fileError.message : "文件不存在或不可预览"}
</div>
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode: "file",
title: fileData.name,
file: fileData,
}}
/>
</div>
</ScrollArea>
)
) : (
<ScrollArea className="h-full">
<div className="mx-auto max-w-5xl p-6">
<PublicFullscreenPreviewContent
preview={{
mode:
run?.result?.format === "markdown" || run?.result?.format === "mixed"
? "markdown"
: "text",
title: run?.result?.title || "正文预览",
content: run?.result?.content || "无正文内容",
}}
/>
</div>
</ScrollArea>
)}
</div>
</div>
</TooltipProvider>
);
}