Files
UHD_Media_Tool/apps/web/app/components/tasks/task-stream.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

301 lines
11 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts";
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { useEngineRuns, useEngineRunsPage } from "~/api/engine.ts";
import { useScheduleBoard } from "~/api/schedules.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
import { Button } from "~/components/ui/button.tsx";
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import {
BUSINESS_KIND_LABEL,
BUSINESS_KINDS,
businessLink,
ENGINE_ACTIVE,
RUN_STATUS_OPTIONS,
runStatus,
runTitle,
SOURCE_LABEL,
sourceOf,
type TaskSource,
workflowLabel,
} from "./labels.ts";
export type StreamTab = "all" | "active" | "failed";
/** 引擎本身不发事件;业务事件足以代表任务在动,2 秒节流刷新 */
const TASK_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"organization.changed",
"agent.run_status",
"schedule.changed",
];
export function useLiveTasks() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(TASK_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
}
const SINCE: { value: string; label: string; ms: number }[] = [
{ value: "1h", label: "1 小时内", ms: 3600_000 },
{ value: "24h", label: "24 小时内", ms: 86400_000 },
{ value: "7d", label: "7 天内", ms: 7 * 86400_000 },
{ value: "30d", label: "30 天内", ms: 30 * 86400_000 },
];
export type StreamFilters = {
status: string;
kind: string;
ref: string;
source: string;
since: string;
workflow: string;
};
function SourceBadge({ r }: { r: EngineRun }) {
const s = sourceOf(r.business?.triggeredBy);
return s ? <span className="text-caption">{SOURCE_LABEL[s]}</span> : <span className="text-muted-foreground">—</span>;
}
/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */
function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void }) {
const board = useScheduleBoard();
const job = board.data?.items.find((j) => j.id === jobId) ?? null;
const [open, setOpen] = useState<ScheduledJob | null>(null);
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 truncate">
定时任务 <span className="font-medium text-foreground">{job?.name ?? jobId}</span> 触发的任务
</span>
<span className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="xs" disabled={!job} onClick={() => setOpen(job)}>
<History />
运行记录
</Button>
<Button asChild variant="ghost" size="xs">
<Link to={`/console/tasks?tab=plan&job=${encodeURIComponent(jobId)}`}>
<ExternalLink />
计划
</Link>
</Button>
<Button variant="ghost" size="icon-xs" aria-label="清除定时任务筛选" onClick={onClear}>
<X />
</Button>
</span>
<RunsSheet job={open} onClose={() => setOpen(null)} />
</div>
);
}
export function TaskStream({
tab,
filters,
setFilter,
resetFilters,
onOpen,
selectedId,
}: {
tab: StreamTab;
filters: StreamFilters;
setFilter: (k: keyof StreamFilters, v: string) => void;
resetFilters: () => void;
onOpen: (id: string) => void;
selectedId: string | null;
}) {
useLiveTasks();
// 时间下界按分钟取整,查询 key 稳定
const from = useMemo(() => {
const ms = SINCE.find((s) => s.value === filters.since)?.ms;
return ms ? new Date(Math.floor((Date.now() - ms) / 60_000) * 60_000).toISOString() : undefined;
}, [filters.since]);
const base = {
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
businessRefId: filters.ref || undefined,
workflowName: filters.workflow || undefined,
from,
};
const list = useEngineRuns(
{
...base,
status: tab === "failed" ? "failed" : filters.status || undefined,
},
tab !== "active",
);
// 进行中 = 进行中 + 排队(两次取第一页合并)
const running = useEngineRunsPage({ ...base, status: "running" }, 100, 10_000, tab === "active");
const pending = useEngineRunsPage({ ...base, status: "pending" }, 100, 10_000, tab === "active");
const active = tab === "active";
const loaded = active
? running.data && pending.data
? [...running.data.items, ...pending.data.items]
: undefined
: list.data?.pages.flatMap((p) => p.items);
const rows = filters.source ? loaded?.filter((r) => sourceOf(r.business?.triggeredBy) === filters.source) : loaded;
const filtered = Object.values(filters).some(Boolean);
return (
<>
{filters.kind === "schedule" && filters.ref ? (
<ScheduleBanner
jobId={filters.ref}
onClear={() => {
setFilter("ref", "");
setFilter("kind", "");
}}
/>
) : filters.ref ? (
<div className="mb-3 flex items-center gap-2 text-caption text-muted-foreground">
只看对象 <code className="font-mono text-foreground">{filters.ref}</code>
<Button variant="ghost" size="icon-xs" aria-label="清除对象筛选" onClick={() => setFilter("ref", "")}>
<X />
</Button>
</div>
) : null}
<FilterBar onReset={filtered ? resetFilters : undefined}>
<FilterSelect
label="来源"
value={filters.source}
onChange={(v) => setFilter("source", v)}
options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))}
/>
<FilterSelect
label="业务"
value={filters.kind}
onChange={(v) => setFilter("kind", v)}
options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))}
/>
{tab === "all" ? (
<FilterSelect
label="状态"
value={filters.status}
onChange={(v) => setFilter("status", v)}
options={RUN_STATUS_OPTIONS}
/>
) : null}
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={SINCE}
allLabel="不限"
/>
{filters.workflow ? (
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
{workflowLabel(filters.workflow)}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<EngineRun>
rows={rows}
rowKey={(r) => r.id}
loading={active ? running.isPending || pending.isPending : list.isPending}
error={active ? (running.error ?? pending.error) : list.error}
onRetry={() => {
if (active) {
void running.refetch();
void pending.refetch();
} else void list.refetch();
}}
onRowClick={(r) => onOpen(r.id)}
selectedKey={selectedId}
hasMore={!active && !!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
empty={{
icon: ListTodo,
title: filtered
? "没有符合条件的任务"
: tab === "active"
? "没有进行中的任务"
: tab === "failed"
? "没有失败的任务"
: "还没有任务",
description: filters.source && loaded?.length ? "来源筛选只作用于已加载的记录,可加载更多再看。" : undefined,
}}
columns={[
{
key: "title",
header: "任务",
cell: (r) => (
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
<p className="truncate text-meta text-muted-foreground">
{r.business
? `${workflowLabel(r.workflowName)} · ${BUSINESS_KIND_LABEL[r.business.kind]}`
: `未关联业务对象 · ${r.id.slice(0, 8)}`}
</p>
</div>
),
},
{ key: "source", header: "来源", hideBelow: "md", cell: (r) => <SourceBadge r={r} /> },
{
key: "status",
header: "状态",
cell: (r) => {
const s = runStatus(r.status);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
},
},
{
key: "time",
header: "开始 / 耗时",
hideBelow: "sm",
cell: (r) => (
<div className="whitespace-nowrap">
<p className="text-muted-foreground" title={r.startedAt ?? r.createdAt}>
{r.startedAt
? formatRelative(r.startedAt)
: ENGINE_ACTIVE.has(r.status)
? `排队 ${formatRelative(r.createdAt)}`
: `未开始 · ${formatRelative(r.createdAt)}`}
</p>
<p className="text-meta text-muted-foreground tabular-nums">{formatDuration(spanSec(r))}</p>
</div>
),
},
{
key: "link",
header: "关联",
hideBelow: "lg",
cell: (r) => {
const l = businessLink(r.business);
return l ? (
<Link
to={l.to}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
</>
);
}