haixunMaster/components/job-progress-panel.tsx

229 lines
8.4 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { Check, Circle, Loader2, X, XCircle } from "lucide-react";
import {
ANALYZE_PIPELINE_STEPS,
AI_TASK_PIPELINE_STEPS,
SCAN_PIPELINE_STEPS,
STYLE_8D_PIPELINE_STEPS,
describeTaskStatus,
formatTaskElapsed,
formatTaskMetric,
getActivePhaseHint,
isPipelineTask,
parseJobProgressDetail,
} from "@/lib/jobs/progress";
import type { JobTaskStatus } from "@/lib/jobs/types";
import { cn } from "@/lib/utils";
const STATUS_ICON: Record<JobTaskStatus, ReactNode> = {
pending: <Circle className="h-3 w-3 text-muted-foreground/50" />,
running: <Loader2 className="h-3 w-3 animate-spin text-foreground" />,
done: <Check className="h-3 w-3 text-success" />,
failed: <XCircle className="h-3 w-3 text-destructive" />,
cancelled: <X className="h-3 w-3 text-muted-foreground" />,
};
interface JobProgressPanelProps {
summary?: string | null;
progressDetailRaw?: string | null;
compact?: boolean;
completed?: boolean;
jobType?: "scan" | "analyze-topic" | "style-8d" | "ai-task";
}
function PhaseStepper({
steps,
currentPhase,
compact,
}: {
steps: ReadonlyArray<{ phase: string; title: string; hint: string }>;
currentPhase?: string;
compact?: boolean;
}) {
const currentIdx = steps.findIndex((s) => s.phase === currentPhase);
return (
<ol className={cn("flex flex-wrap gap-1.5", compact ? "text-[10px]" : "text-[11px]")}>
{steps.map((step, idx) => {
const isPast = currentIdx >= 0 && idx < currentIdx;
const isCurrent = step.phase === currentPhase;
return (
<li
key={step.phase}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5",
isCurrent && "border-primary/40 bg-primary/5 text-foreground",
isPast && "border-success-border bg-success-bg text-foreground",
!isCurrent && !isPast && "border-border text-muted-foreground"
)}
>
{isPast ? (
<Check className="h-2.5 w-2.5 text-success" />
) : isCurrent ? (
<Loader2 className="h-2.5 w-2.5 animate-spin" />
) : (
<Circle className="h-2.5 w-2.5 opacity-40" />
)}
<span>{step.title}</span>
</li>
);
})}
</ol>
);
}
export function JobProgressPanel({
summary,
progressDetailRaw,
compact = false,
completed = false,
jobType = "scan",
}: JobProgressPanelProps) {
const detail = parseJobProgressDetail(progressDetailRaw);
const tasks = detail?.tasks ?? [];
const phase = detail?.phase;
const phaseHint = getActivePhaseHint(phase, jobType);
const hasRunning = !completed && tasks.some((t) => t.status === "running");
const [, tick] = useState(0);
useEffect(() => {
if (!hasRunning) return;
const timer = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(timer);
}, [hasRunning]);
if (!summary && tasks.length === 0) return null;
const pipelineTasks = tasks.filter((t) => isPipelineTask(t));
const keywordTasks = tasks.filter((t) => !isPipelineTask(t));
const keywordDone = keywordTasks.filter((t) => t.status === "done").length;
const keywordRunning = keywordTasks.find((t) => t.status === "running");
const keywordTotal = keywordTasks.length;
const collapseKeywords = keywordTotal > 3;
const steps =
jobType === "analyze-topic"
? ANALYZE_PIPELINE_STEPS
: jobType === "style-8d"
? STYLE_8D_PIPELINE_STEPS
: jobType === "ai-task"
? AI_TASK_PIPELINE_STEPS
: SCAN_PIPELINE_STEPS;
return (
<div className={cn("space-y-2.5", compact ? "text-[12px]" : "text-[13px]")}>
{!completed && phase && (
<div className="space-y-1.5">
<PhaseStepper steps={steps} currentPhase={phase} compact={compact} />
{phaseHint && (
<p className="text-[11px] text-muted-foreground">{phaseHint}</p>
)}
</div>
)}
{(detail?.summary || summary) && (
<p className={cn("text-muted-foreground", completed && "font-medium text-foreground")}>
{detail?.summary ?? summary}
</p>
)}
{pipelineTasks.length > 0 && (
<ul className="space-y-1 rounded-md border border-border bg-background/60 p-2">
{pipelineTasks.map((task) => (
<li key={task.id} className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">{STATUS_ICON[task.status]}</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-foreground">{task.label}</span>
<span className="text-[11px] text-muted-foreground">
{describeTaskStatus(task, phase)}
{formatTaskMetric(task)}
</span>
</div>
{task.status === "running" && task.step && (
<p className="text-[11px] text-foreground/80">
{task.step}
{task.stepDetail ? `${task.stepDetail}` : ""}
{formatTaskElapsed(task.startedAt)}
</p>
)}
{task.error && (
<p className="text-[11px] text-destructive">{task.error}</p>
)}
</div>
</li>
))}
</ul>
)}
{keywordTotal > 0 && (
<div className="rounded-md border border-border bg-background/60 p-2">
{collapseKeywords ? (
<div className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">
{keywordRunning ? STATUS_ICON.running : keywordDone === keywordTotal ? STATUS_ICON.done : STATUS_ICON.pending}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-foreground"></span>
<span className="text-[11px] text-muted-foreground">
{keywordRunning
? `Threads 搜尋中… · ${keywordDone}/${keywordTotal}`
: keywordDone === keywordTotal
? `完成 · ${keywordTotal} 個詞`
: `等待 · 0/${keywordTotal}`}
</span>
</div>
{keywordRunning && (
<div className="mt-0.5 space-y-0.5 text-[11px] text-muted-foreground">
<p className="truncate">
{keywordRunning.label}
{formatTaskElapsed(keywordRunning.startedAt)}
</p>
{keywordRunning.step && (
<p className="truncate text-foreground/80">
{keywordRunning.step}
{keywordRunning.stepDetail ? `${keywordRunning.stepDetail}` : ""}
</p>
)}
</div>
)}
</div>
</div>
) : (
<ul className="space-y-1">
{keywordTasks.map((task) => (
<li key={task.id} className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">{STATUS_ICON[task.status]}</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="font-medium text-foreground">{task.label}</span>
<span className="text-[11px] text-muted-foreground">
{describeTaskStatus(task, phase)}
{formatTaskMetric(task)}
</span>
</div>
{task.status === "running" && task.step && (
<p className="text-[11px] text-foreground/80">
{task.step}
{task.stepDetail ? `${task.stepDetail}` : ""}
{formatTaskElapsed(task.startedAt)}
</p>
)}
{task.error && (
<p className="text-[11px] text-destructive">{task.error}</p>
)}
</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}