haixunMaster/components/job-progress-panel.tsx

229 lines
8.4 KiB
TypeScript
Raw Permalink 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.

"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>
);
}