229 lines
8.4 KiB
TypeScript
229 lines
8.4 KiB
TypeScript
"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>
|
||
);
|
||
}
|