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 { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { useQueryState } from "~/components/console/use-query-state.ts"; import { Button } from "~/components/ui/button.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage, TTL_SECOND } from "~/lib/api.ts"; import { actorText } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.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" }, }; export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"]; export const SUBJECT_LABEL: Record = { download_task: "下载任务", acquisition_plan: "下载计划", acquisition_slot: "版本目标", organization_record: "整理记录", organize_request: "整理请求", media_entity: "作品", media_asset: "资产", source_release: "资源", feed_source: "Feed", site: "站点", library_root: "库根", cache_volume: "缓存卷", }; /** 业务任务的对象 → 它的"家"页面 */ export function subjectLink(t: Pick): string | null { if (!t.subjectId) return null; switch (t.subjectType) { case "download_task": return consolePath("downloads", t.subjectId); case "acquisition_plan": return consolePath("downloads", "plans"); case "organization_record": return consolePath("organize", t.subjectId); case "media_entity": return consolePath("library", t.subjectId); case "site": return consolePath("sites", t.subjectId); case "feed_source": return consolePath("sources"); default: return null; } } export function subjectText(t: Pick): string { if (!t.subjectType) return "—"; const label = SUBJECT_LABEL[t.subjectType] ?? t.subjectType; return t.subjectId ? `${label} · ${t.subjectId.slice(0, 8)}` : label; } /** 取消 / 重试 */ export function useTaskAction() { const qc = useQueryClient(); return 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)), }); } /** 取消 / 重试按钮(按状态二选一或都不显示) */ export function TaskActions({ t, size = "sm" }: { t: Task; size?: "sm" | "xs" }) { const action = useTaskAction(); if (ACTIVE_TASK.includes(t.status)) return ( ); if (t.status === "failed" || t.status === "dead") return ( ); return null; } export function BusinessTasks() { const f = useQueryState(["kind", "status"] as const); const { kind, status } = f.values; const [openId, setOpenId] = useState(null); 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_TASK.includes(t.status))) ? 5 * TTL_SECOND : false, }); const rows = list.data?.pages.flatMap((p) => p.items); const open = rows?.find((t) => t.id === openId) ?? null; const columns: DataColumn[] = [ { key: "kind", header: "任务", cell: (t) => (

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

{subjectText(t)}

), }, { 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) => {actorText(t.triggeredBy)}, }, { key: "at", header: "创建", hideBelow: "sm", cell: (t) => {relTime(t.createdAt)}, }, { key: "ops", header: "", align: "right", cell: (t) => }, ]; return ( <> f.set("kind", v)} options={Object.entries(TASK_KIND_LABEL).map(([value, label]) => ({ value, label }))} /> f.set("status", v)} options={Object.entries(TASK_STATUS).map(([value, v]) => ({ value, label: v.label }))} /> t.id} loading={list.isPending} error={list.error} onRetry={() => void list.refetch()} onRowClick={(t) => setOpenId(t.id)} selectedKey={openId} hasMore={list.hasNextPage} onLoadMore={() => void list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ icon: ListTodo, title: f.active ? "没有符合筛选的任务" : "还没有业务任务", description: f.active ? undefined : "提交下载、核验入库、清理缓存、对账等后台任务会出现在这里。", }} /> !o && setOpenId(null)} title={open ? (TASK_KIND_LABEL[open.kind] ?? open.kind) : ""} description={open ? : undefined} footer={ open ? ( <> ) : null } > {open ? : null} ); } export function TaskStatusLine({ t }: { t: Task }) { return ( {TASK_STATUS[t.status].label} {t.waitReason ? {t.waitReason} : null} ); } /** 概况 + 关联对象(抽屉与详情页共用) */ export function TaskSummary({ t }: { t: Task }) { const href = subjectLink(t); const err = t.lastError ? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError)) : null; return (
{err ? (

{err}

) : null} {subjectText(t)} ) : ( subjectText(t) ), }, { label: "上级任务", value: t.parentTaskId ? ( {t.parentTaskId.slice(0, 8)} ) : null, }, { label: "引擎运行", value: t.engineRunId ? ( {t.engineRunId.slice(0, 12)} ) : null, }, ]} />
); }