Files
UHD_Media_Tool/apps/web/app/components/tasks/task-detail.tsx
T
rootandClaude Opus 5.5 d8b9bd9662 feat(web): unified task stream with plan tab, task detail drawer and audit links (FE-1)
Tasks page merges engine runs (with W9-I business fields) into one stream:
all / active / failed / plan tabs, filters for source, business kind, status
and time; detail drawer with business summary, timeline, collapsed engine
steps, cancel/retry and related audit links. The schedule board moves into
the plan tab; card history jumps to the stream filtered by schedule key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:10:29 +08:00

352 lines
13 KiB
TypeScript

import type { EngineRunDetail, EngineStepAttempt } from "@umt/contracts";
import { Ban, ChevronDown, ExternalLink, Repeat, ScrollText } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun, useRetryEngineRun } from "~/api/engine.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { BUSINESS_KIND_LABEL, businessLink, runStatus, SOURCE_LABEL, sourceOf, workflowLabel } from "./labels.ts";
// 任务详情:业务摘要 → 时间线 → 引擎步骤(折叠)→ 操作。任务页抽屉与 /tasks/:id 共用。
const KIND_LABEL: Record<string, string> = {
function: "函数",
sleep: "休眠",
workflow: "子工作流",
"signal-send": "发送信号",
"signal-wait": "等待信号",
};
function stepTone(s: string) {
if (s === "completed" || s === "succeeded") return "success" as const;
if (s === "failed") return "destructive" as const;
return "neutral" as const;
}
/** 错误对象里的一句话 */
export function errorText(e: unknown): string | null {
if (e === null || e === undefined) return null;
if (typeof e === "string") return e;
const m = (e as { message?: unknown }).message;
return typeof m === "string" ? m : JSON.stringify(e).slice(0, 300);
}
/** 引擎步骤尝试(原引擎面板详情) */
function stepItems(steps: EngineStepAttempt[], onOpenRun?: (id: string) => void): TimelineItem[] {
const seen = new Map<string, number>();
return steps.map((st) => {
const n = (seen.get(st.stepName) ?? 0) + 1;
seen.set(st.stepName, n);
const hasError = st.error !== null && st.error !== undefined;
return {
id: st.id,
at: st.startedAt ?? st.createdAt,
tone: stepTone(st.status),
title: (
<span>
{st.stepName}
<span className="ml-2 text-meta font-normal text-muted-foreground">
{KIND_LABEL[st.kind] ?? st.kind} · {runStatus(st.status).label}
{n > 1 ? ` · 第 ${n} 次` : ""}
</span>
</span>
),
meta: formatDuration(spanSec(st)),
description: (
<>
{st.waitSignal ? (
<p>
等待信号 <code className="font-mono">{st.waitSignal}</code>
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
</p>
) : null}
{st.childWorkflowRunId ? (
<p>
子任务{" "}
<button
type="button"
className="cursor-pointer font-mono underline"
onClick={() => onOpenRun?.(st.childWorkflowRunId ?? "")}
>
{st.childWorkflowRunId}
</button>
</p>
) : null}
<p className="font-mono text-meta">{st.id}</p>
</>
),
children: hasError ? (
<JsonView label="错误" value={st.error} tone="destructive" defaultOpen />
) : st.output !== null && st.output !== undefined ? (
<JsonView label="输出" value={st.output} defaultOpen={false} maxHeight="14rem" />
) : undefined,
};
});
}
/** 业务时间线:创建 → 开始 → 等待 / 失败的步骤 → 结束 */
function businessTimeline(r: EngineRunDetail): TimelineItem[] {
const src = sourceOf(r.business?.triggeredBy);
const items: TimelineItem[] = [
{ id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" },
];
if (r.startedAt)
items.push({
id: "started",
at: r.startedAt,
title: r.attempts > 1 ? `开始执行 · 第 ${r.attempts} 次尝试` : "开始执行",
tone: "neutral",
});
for (const st of r.stepAttempts) {
if (st.waitSignal && !st.finishedAt)
items.push({ id: `w-${st.id}`, at: st.startedAt ?? st.createdAt, title: `等待:${st.stepName}`, tone: "warning" });
else if (st.status === "failed")
items.push({
id: `f-${st.id}`,
at: st.finishedAt ?? st.startedAt ?? st.createdAt,
title: `步骤失败:${st.stepName}`,
description: errorText(st.error),
tone: "destructive",
});
}
if (r.finishedAt) {
const s = runStatus(r.status);
items.push({
id: "finished",
at: r.finishedAt,
title: s.label,
// 与最后一个失败步骤相同的错误不再重复
description:
r.status === "failed" && !r.stepAttempts.some((st) => errorText(st.error) === errorText(r.error))
? errorText(r.error)
: undefined,
tone: s.tone,
meta: formatDuration(spanSec(r)),
});
} else if (r.startedAt) {
items.push({ id: "now", title: "进行中", tone: "neutral", meta: `已 ${formatDuration(spanSec(r))}` });
}
return items;
}
/** 取消 / 重跑 */
export function RunActions({
r,
onOpenRun,
size = "sm",
}: {
r: EngineRunDetail;
onOpenRun?: (id: string) => void;
size?: "sm" | "xs";
}) {
const cancel = useCancelEngineRun();
const retry = useRetryEngineRun();
if (!ENGINE_TERMINAL.has(r.status))
return (
<ConfirmDialog
trigger={
<Button variant="destructive" size={size}>
<Ban />
取消任务
</Button>
}
title="取消这个任务?"
description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。"
confirmLabel="取消任务"
cancelLabel="不取消"
destructive
onConfirm={async () => {
try {
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
if (res.canceled) toast.success("已取消");
else toast.warning("任务已结束");
} catch (err) {
toast.error(errorMessage(err, "取消失败"));
throw err;
}
}}
/>
);
return (
<ConfirmDialog
trigger={
<Button variant="outline" size={size} disabled={retry.isPending}>
<Repeat />
重跑
</Button>
}
title="重跑这个任务?"
description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。"
confirmLabel="重跑"
cancelLabel="不重跑"
onConfirm={async () => {
try {
const res = await retry.mutateAsync({ id: r.id });
toast.success(`已重新排队(第 ${res.attempt} 次重跑)`);
onOpenRun?.(res.runId);
} catch (err) {
toast.error(errorMessage(err, "重跑失败"));
throw err;
}
}}
/>
);
}
function Collapsible({ title, children }: { title: string; children: ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center gap-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase hover:text-foreground"
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !open && "-rotate-90")} />
{title}
</button>
{open ? <div className="mt-3 space-y-4">{children}</div> : null}
</section>
);
}
/** 详情主体(不含外框);抽屉和独立页都用 */
export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun?: (id: string) => void }) {
const b = r.business;
const link = businessLink(b, r.input);
const src = sourceOf(b?.triggeredBy);
const hasError = r.error !== null && r.error !== undefined;
const audits = [
{ to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" },
...(b?.kind === "schedule"
? [
{
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
label: "定时任务的修改记录",
},
]
: []),
];
return (
<>
<DetailSection title="业务摘要">
<KvList
items={[
{
label: "关联对象",
value: b ? (
link ? (
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
`${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}`
)
) : (
"无"
),
wide: true,
},
{ label: "做什么", value: workflowLabel(r.workflowName) },
{ label: "来源", value: src ? SOURCE_LABEL[src] : "未知" },
{
label: "开始",
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
},
{ label: "耗时", value: formatDuration(spanSec(r)) },
]}
/>
</DetailSection>
<DetailSection title="时间线">
<Timeline items={businessTimeline(r)} />
</DetailSection>
<DetailSection title="相关审计">
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
{audits.map((a) => (
<li key={a.to}>
<Link
to={a.to}
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<ScrollText className="h-3.5 w-3.5" />
{a.label}
</Link>
</li>
))}
</ul>
</DetailSection>
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
<KvList
items={[
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
{ label: "运行 ID", value: r.id, mono: true },
{ label: "尝试次数", value: r.attempts },
{ label: "Worker", value: r.workerId, mono: true },
{ label: "可执行于", value: formatDateTime(r.availableAt) },
{ label: "截止", value: formatDateTime(r.deadlineAt) },
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
]}
/>
{r.stepAttempts.length === 0 ? (
<EmptyState plain title="还没有步骤" className="py-6" />
) : (
<Timeline items={stepItems(r.stepAttempts, onOpenRun)} />
)}
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
<JsonView label="输入" value={r.input} defaultOpen={false} />
{!hasError ? <JsonView label="输出" value={r.output} defaultOpen={false} /> : null}
{r.signals.length ? (
<div className="space-y-2">
<p className="text-meta text-muted-foreground">信号({r.signals.length})</p>
{r.signals.map((sg) => (
<div key={sg.id} className="min-w-0">
<p className="text-sm">
<code className="font-mono">{sg.signal}</code>
<span className="ml-2 text-meta text-muted-foreground">{formatDateTime(sg.createdAt)}</span>
</p>
<JsonView value={sg.data} maxHeight="10rem" className="mt-1" />
</div>
))}
</div>
) : null}
</Collapsible>
</>
);
}
/** 头部一行:状态 + 业务类型 */
export function TaskStatusLine({ r }: { r: EngineRunDetail }) {
const s = runStatus(r.status);
return (
<span className="flex flex-wrap items-center gap-2">
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
<span>{workflowLabel(r.workflowName)}</span>
{r.business ? <span>· {BUSINESS_KIND_LABEL[r.business.kind]}</span> : null}
</span>
);
}
/** 按 id 取数并渲染(抽屉用) */
export function TaskDetail({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) {
const q = useEngineRun(runId);
if (q.isPending) return <LoadingState rows={6} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
return <TaskDetailBody r={q.data} onOpenRun={onOpenRun} />;
}