Files
UHD_Media_Tool/apps/web/app/components/tasks/task-detail.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

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

357 lines
13 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.
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} />;
}