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