- 定时任务执行时按 agent.id 解析智能体,老数据中存的中文显示名会回退按 name 查找,避免 422
- scheduled_tasks 表新增 description 字段(迁移 20260522_01),创建/编辑表单支持,公开页标题下显示
- 公开页(/public/scheduled-tasks/{id})新增删除单次执行记录入口,使用样式化 Dialog 确认
- 公开页左侧标题区精简:去掉 schedule/timezone/最近状态两行;执行列表标题剥离尾部时间戳,"计划"改为"执行时间"
- 工作区定时任务列表:超长标题改为按字符换行不再截断;按 created_at 倒序(新建任务在前)
- 启动 Alembic 升级加超时与单独线程,MySQL 缺库时自动 CREATE DATABASE
- vite build 增加 8GB 内存上限
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
818 lines
28 KiB
TypeScript
818 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
Download,
|
|
FileText,
|
|
Loader2,
|
|
Pencil,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
|
import { getBackendBaseURL } from "@/core/config";
|
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
|
import { reasoningPlugins } from "@/core/streamdown";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type PublicTask = {
|
|
task_id: string;
|
|
name: string;
|
|
description?: string | null;
|
|
schedule_text: string;
|
|
cron_expr?: string;
|
|
timezone: string;
|
|
enabled: boolean;
|
|
next_run_at?: string | null;
|
|
last_run_at?: string | null;
|
|
last_status?: string | null;
|
|
published?: boolean;
|
|
};
|
|
|
|
type PublicRunFile = {
|
|
index: number;
|
|
name: string;
|
|
mime_type?: string | null;
|
|
kind?: string | null;
|
|
size?: number | null;
|
|
/** Server-reported "is the archive file still on disk?" flag. Older responses
|
|
* may omit this — treat missing as available. */
|
|
available?: boolean;
|
|
};
|
|
|
|
type PublicRunResult = {
|
|
title: string;
|
|
content: string;
|
|
format: "markdown" | "text" | "json" | "mixed";
|
|
files: PublicRunFile[];
|
|
created_at: string;
|
|
};
|
|
|
|
type PublicRun = {
|
|
id: string;
|
|
task_id: string;
|
|
scheduled_for: string;
|
|
started_at?: string | null;
|
|
finished_at?: string | null;
|
|
status: "pending" | "running" | "succeeded" | "failed" | string;
|
|
error?: string | null;
|
|
result?: PublicRunResult | null;
|
|
retry_count?: number | null;
|
|
created_at?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
type PublicRunsResponse = {
|
|
task: PublicTask;
|
|
total: number;
|
|
runs: PublicRun[];
|
|
};
|
|
|
|
type PublicFileContent = {
|
|
index: number;
|
|
name: string;
|
|
mime_type?: string | null;
|
|
kind?: string | null;
|
|
size?: number | null;
|
|
previewable: boolean;
|
|
content?: string | null;
|
|
};
|
|
|
|
function publicBaseUrl() {
|
|
return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
|
|
}
|
|
|
|
async function fetchJson<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 fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
|
|
return fetchJson<PublicFileContent>(
|
|
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
|
);
|
|
}
|
|
|
|
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() : "暂无";
|
|
}
|
|
|
|
function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
|
|
const name = file.name.toLowerCase();
|
|
return (
|
|
file.kind === "markdown" ||
|
|
file.mime_type === "text/markdown" ||
|
|
name.endsWith(".md") ||
|
|
name.endsWith(".markdown")
|
|
);
|
|
}
|
|
|
|
function isFileAvailable(file: PublicRunFile): boolean {
|
|
// Older responses omit `available`; treat that as still available so we
|
|
// don't accidentally hide content from a backend that hasn't shipped the
|
|
// flag yet.
|
|
return file.available !== false;
|
|
}
|
|
|
|
function stripExtension(name: string): string {
|
|
if (!name) return name;
|
|
const dot = name.lastIndexOf(".");
|
|
if (dot <= 0) return name;
|
|
return name.slice(0, dot);
|
|
}
|
|
|
|
/**
|
|
* Strip a trailing ` - YYYY-MM-DD HH:MM[:SS]` (or `_YYYY-MM-DD ...`) suffix
|
|
* that the agent appends to run titles. The scheduled-for column is already
|
|
* displayed separately below the title, so the duplicated date looks like noise.
|
|
*/
|
|
function stripTimestampSuffix(title: string): string {
|
|
if (!title) return title;
|
|
const cleaned = title.replace(
|
|
/[\s_]*[-_]?\s*\d{4}-\d{1,2}-\d{1,2}[\sT]+\d{1,2}:\d{2}(?::\d{2})?\s*$/,
|
|
"",
|
|
);
|
|
return cleaned.trim() || title;
|
|
}
|
|
|
|
const BODY_TAB_VALUE = "__body__";
|
|
|
|
/**
|
|
* Public, unauthenticated scheduled-task viewer.
|
|
*
|
|
* URL: `#/public/scheduled-tasks/<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 queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, isError, error } = useQuery({
|
|
queryKey: ["public-scheduled-task", taskId],
|
|
queryFn: () => fetchPublicTaskRuns(taskId!),
|
|
enabled: !!taskId,
|
|
retry: false,
|
|
refetchOnWindowFocus: false,
|
|
refetchInterval: 8000,
|
|
});
|
|
|
|
const deleteRunMutation = useMutation({
|
|
mutationFn: (runId: string) => deletePublicRun(runId),
|
|
onSuccess: async (_data, runId) => {
|
|
toast.success("已删除");
|
|
if (selectedRunId === runId) {
|
|
setSelectedRunId(null);
|
|
}
|
|
setPendingDeleteRun(null);
|
|
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err instanceof Error ? err.message : "删除失败");
|
|
},
|
|
});
|
|
|
|
// Public viewer only surfaces runs that have actual content to show:
|
|
// pending/running/failed are hidden, and succeeded runs with neither a body
|
|
// nor a Markdown attachment fall out too (nothing to render).
|
|
const runs = useMemo(() => {
|
|
const all = data?.runs ?? [];
|
|
return all.filter((run) => {
|
|
if (run.status !== "succeeded") return false;
|
|
const result = run.result;
|
|
if (!result) return false;
|
|
const hasBody = !!result.content && result.content.trim().length > 0;
|
|
const hasMarkdownFile = (result.files ?? []).some(
|
|
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
|
);
|
|
return hasBody || hasMarkdownFile;
|
|
});
|
|
}, [data]);
|
|
const selectedRun = useMemo(
|
|
() => runs.find((r) => r.id === selectedRunId) ?? null,
|
|
[runs, selectedRunId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedRunId && runs.length > 0) {
|
|
setSelectedRunId(runs[0]!.id);
|
|
}
|
|
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
|
|
setSelectedRunId(runs[0]!.id);
|
|
}
|
|
}, [runs, selectedRunId]);
|
|
|
|
if (!taskId) {
|
|
return (
|
|
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
|
|
缺少任务 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">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
正在加载任务记录...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isError) {
|
|
return (
|
|
<div className="text-destructive flex h-screen items-center justify-center text-sm">
|
|
加载失败:{error instanceof Error ? error.message : "未知错误"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const task = data?.task;
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={150}>
|
|
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
|
|
<Card className="flex min-h-0 flex-col">
|
|
<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>
|
|
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
|
|
{task?.enabled ? "启用" : "暂停"}
|
|
</Badge>
|
|
</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 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={runTitle}
|
|
>
|
|
{runTitle}
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="max-w-[360px] break-all">
|
|
{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 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 — `__body__` for inline content, `file-<index>` for files. */
|
|
value: string;
|
|
/** Short label rendered in the TabsTrigger. */
|
|
label: string;
|
|
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */
|
|
kind: "body" | "file";
|
|
/** File index (when `kind === "file"`). */
|
|
fileIndex?: number;
|
|
/** Filename for downloads (defaults to the run title for body). */
|
|
fileName?: string;
|
|
};
|
|
|
|
/**
|
|
* Right-pane controller for a single run: builds the tab list (body + MDs),
|
|
* fetches each tab's source text, and hosts the view/edit toggle.
|
|
*/
|
|
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
|
const queryClient = useQueryClient();
|
|
|
|
// Skip files whose archive is missing on disk — otherwise the public viewer
|
|
// would render a tab that 404s with "Result file is no longer available".
|
|
const markdownFiles = useMemo(
|
|
() =>
|
|
(run.result?.files ?? []).filter(
|
|
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
|
),
|
|
[run.result?.files],
|
|
);
|
|
|
|
// Tabs: every MD file first (priority per spec), then the inline body.
|
|
const tabs: ContentTab[] = useMemo(() => {
|
|
const list: ContentTab[] = markdownFiles.map((file, idx) => ({
|
|
value: `file-${file.index}`,
|
|
// Show the actual filename without the `.md` suffix — the user explicitly
|
|
// asked for "no '文件' label, just the name without extension".
|
|
label: stripExtension(file.name) || `文件 ${idx + 1}`,
|
|
kind: "file",
|
|
fileIndex: file.index,
|
|
fileName: file.name,
|
|
}));
|
|
list.push({
|
|
value: BODY_TAB_VALUE,
|
|
label: "正文",
|
|
kind: "body",
|
|
fileName: run.result?.title || "scheduled-task-result",
|
|
});
|
|
return list;
|
|
}, [markdownFiles, run.result?.title]);
|
|
|
|
const [activeTab, setActiveTab] = useState<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;
|
|
|
|
return (
|
|
<>
|
|
<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>
|
|
<p className="text-muted-foreground mt-1 text-xs">
|
|
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{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>
|
|
)}
|
|
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
|
<FileText className="size-4" />
|
|
下载 Word
|
|
</Button>
|
|
{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>
|
|
)}
|
|
{fileIsLoading ? (
|
|
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
<Loader2 className="size-4 animate-spin" />
|
|
正在加载文件内容...
|
|
</div>
|
|
) : isEditing ? (
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
className="min-h-0 flex-1 resize-none font-mono text-sm"
|
|
spellCheck={false}
|
|
/>
|
|
) : (
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
{showEmptyHint ? (
|
|
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
|
无内容。点击「编辑」开始添加。
|
|
</div>
|
|
) : (
|
|
<MarkdownContent
|
|
className="max-w-none pr-2"
|
|
content={activeSource}
|
|
isLoading={false}
|
|
remarkPlugins={reasoningPlugins.remarkPlugins}
|
|
rehypePlugins={reasoningPlugins.rehypePlugins}
|
|
/>
|
|
)}
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
</Tabs>
|
|
)}
|
|
</CardContent>
|
|
</>
|
|
);
|
|
}
|