1559 lines
56 KiB
TypeScript
1559 lines
56 KiB
TypeScript
"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/<task_id></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>
|
||
);
|
||
}
|