import { keepPreviousData, useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { cursorPage, Task, type TaskKind, type TaskStatus } from "@umt/contracts"; import { ListTodo, RotateCcw, Square } from "lucide-react"; import { useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { apiMutate, apiParsed, cleanParams } from "~/api/parse.ts"; import { type DataColumn, 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 { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage, TTL_SECOND } from "~/lib/api.ts"; // 业务任务(/tasks):引擎只负责推进,task 表是面向用户的投影;取消只停后续步骤,重试只对失败任务。 const TaskPage = cursorPage(Task); export const TASK_KIND_LABEL: Record = { ingestFeed: "抓取 Feed", resolveObservation: "识别资源", evaluateAcquisition: "评估候选", submitDownload: "提交下载", verifyAndImport: "核验入库", refreshMetadata: "刷新元数据", reconcileLibrary: "资产对账", cleanupDownloadCache: "清理缓存", resolveDecision: "处理待办", agentRun: "Agent 运行", }; export const TASK_STATUS: Record< TaskStatus, { label: string; tone: "success" | "warning" | "destructive" | "neutral" } > = { pending: { label: "排队", tone: "neutral" }, running: { label: "运行中", tone: "neutral" }, waiting: { label: "等待", tone: "warning" }, succeeded: { label: "成功", tone: "success" }, failed: { label: "失败", tone: "destructive" }, cancelled: { label: "已取消", tone: "neutral" }, dead: { label: "放弃", tone: "destructive" }, }; const ACTIVE: TaskStatus[] = ["pending", "running", "waiting"]; function subjectLink(t: Task): string | null { if (!t.subjectId) return null; if (t.subjectType === "download_task") return `/console/tasks/${t.subjectId}`; if (t.subjectType === "organization_record") return `/console/organize/${t.subjectId}`; if (t.subjectType === "media_entity") return `/console/library/${t.subjectId}`; return null; } export function BusinessTasks() { const [kind, setKind] = useState(""); const [status, setStatus] = useState(""); const qc = useQueryClient(); const list = useInfiniteQuery({ queryKey: ["tasks", "list", kind, status], initialPageParam: undefined as string | undefined, placeholderData: keepPreviousData, queryFn: ({ pageParam, signal }) => apiParsed(TaskPage, "/api/v1/tasks", { params: cleanParams({ kind, status, cursor: pageParam, limit: 50 }), signal, }), getNextPageParam: (last) => last.nextCursor ?? undefined, refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((t) => ACTIVE.includes(t.status))) ? 5 * TTL_SECOND : false, }); const action = useMutation({ mutationFn: ({ id, op }: { id: string; op: "cancel" | "retry" }) => apiMutate(`/api/v1/tasks/:id/${op}`, { pathParams: { id }, body: op === "cancel" ? { reason: "控制台取消" } : {}, }).then((d) => Task.parse(d)), onSuccess: (_t, v) => { toast.success(v.op === "cancel" ? "已请求取消(只停后续步骤)" : "已重新排队"); void qc.invalidateQueries({ queryKey: ["tasks"] }); }, onError: (e) => toast.error(errorMessage(e)), }); const rows = list.data?.pages.flatMap((p) => p.items); const columns: DataColumn[] = [ { key: "kind", header: "任务", cell: (t) => { const href = subjectLink(t); return (

{TASK_KIND_LABEL[t.kind] ?? t.kind}

{href ? ( {t.subjectType} · {t.subjectId?.slice(0, 8)} ) : (

{t.subjectType ?? "—"}

)}
); }, }, { key: "status", header: "状态", cell: (t) => (
{TASK_STATUS[t.status].label} {t.waitReason ? {t.waitReason} : null}
), }, { key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (t) => ( {t.attempts}/{t.maxAttempts} ), }, { key: "by", header: "触发", hideBelow: "lg", cell: (t) => {t.triggeredBy ?? "—"}, }, { key: "at", header: "创建", hideBelow: "sm", cell: (t) => {relTime(t.createdAt)}, }, { key: "ops", header: "", align: "right", cell: (t) => (
{t.engineRunId ? ( ) : null} {ACTIVE.includes(t.status) ? ( ) : null} {t.status === "failed" || t.status === "dead" ? ( ) : null}
), }, ]; return ( <> { setKind(""); setStatus(""); } : undefined } > ({ value, label }))} /> ({ value, label: v.label }))} /> t.id} loading={list.isPending} error={list.error} onRetry={() => void list.refetch()} hasMore={list.hasNextPage} onLoadMore={() => void list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ icon: ListTodo, title: kind || status ? "没有符合筛选的任务" : "还没有业务任务", description: "提交下载、核验入库、缓存清理、对账等后台工作都会记在这里。", }} /> ); }