deerflow-code/frontend-web/src/pages/PublicScheduledTaskPage.tsx
2026-09-07 18:24:55 +08:00

1559 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<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>;
}
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 (
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-background">
<CodeMirror
value={value}
onChange={onChange}
theme={resolvedTheme === "dark" ? markdownEditorDarkTheme : markdownEditorLightTheme}
extensions={extensions}
basicSetup={{
foldGutter: true,
highlightActiveLine: true,
highlightActiveLineGutter: false,
lineNumbers: true,
}}
className="h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!"
/>
</div>
);
}
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 ? (
<PublicMarkdownEditor value={draft} onChange={setDraft} />
) : (
<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>
);
}