haixunMaster/components/layout/active-jobs-panel.tsx

178 lines
9.1 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
"use client";
import Link from "next/link";
import { CheckCircle2, ChevronUp, Loader2, X, XCircle } from "lucide-react";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useJobs } from "@/components/layout/jobs-provider";
import { JobProgressPanel } from "@/components/job-progress-panel";
import { JOB_LABELS } from "@/lib/jobs/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function ActiveJobsPanel({ floating = false }: { floating?: boolean }) {
const { jobs, activeJobs, cancelJob, cancellingId } = useJobs();
const [expanded, setExpanded] = useState(false);
const progressType = (type: string) =>
type === "analyze-topic" ? "analyze-topic" : type === "style-8d" ? "style-8d" : type === "ai-task" ? "ai-task" : "scan";
const recentFinished = jobs
.filter((job) => {
if (job.status !== "completed" && job.status !== "failed") return false;
const finishedAt = new Date(job.completedAt ?? job.createdAt).getTime();
const age = Date.now() - finishedAt;
const window = job.type === "ai-task" ? 30 * 1000 : 3 * 60 * 1000;
return age < window;
})
.slice(0, 4);
const visibleJobs = [...activeJobs, ...recentFinished.filter((job) => !activeJobs.some((active) => active.id === job.id))];
if (!floating && visibleJobs.length === 0) return null;
if (floating) {
const current = activeJobs[0] ?? recentFinished[0];
const running = activeJobs.length > 0;
const panel = (
<div className="fixed right-4 top-4 z-[60] flex flex-col items-end gap-2 lg:right-6 lg:top-6">
<div className="flex items-center gap-2">
{running && (
<span className="hidden max-w-[180px] truncate rounded-full bg-card/95 px-3 py-1.5 text-xs font-medium text-foreground shadow-lg backdrop-blur-xl sm:block">
{current.progress ?? current.label ?? JOB_LABELS[current.type as keyof typeof JOB_LABELS] ?? "任務進行中…"}
</span>
)}
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className={`relative flex h-10 w-10 items-center justify-center rounded-full border shadow-lg backdrop-blur-xl transition hover:-translate-y-0.5 ${
running
? "border-primary/40 bg-primary/10 text-primary"
: current
? current.status === "failed"
? "border-danger-border bg-danger-bg text-destructive"
: "border-success-border bg-success-bg text-success"
: "border-border bg-card/95 text-muted-foreground"
}`}
aria-expanded={expanded}
aria-label="任務中心"
>
<span className="text-sm font-bold"></span>
{running && (
<span className="absolute inset-0 animate-ping rounded-full border border-primary/30" />
)}
{visibleJobs.length > 1 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-bold leading-none text-primary-foreground">
{visibleJobs.length}
</span>
)}
</button>
</div>
{expanded && (
<Card className="max-h-[min(60vh,520px)] w-[min(360px,calc(100vw-2rem))] overflow-y-auto border-primary/25 bg-card/95 shadow-2xl backdrop-blur-xl">
<CardHeader className="sticky top-0 z-10 flex-row items-center justify-between bg-card/95 pb-2 backdrop-blur-xl">
<div>
<CardTitle className="text-base"></CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{visibleJobs.length === 0 ? "目前沒有任務" : "切換頁面不會中斷"}
</p>
</div>
<Button size="sm" variant="ghost" className="h-8 w-8 p-0" onClick={() => setExpanded(false)} aria-label="收合任務進度">
<ChevronUp className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-3">
{visibleJobs.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
) : (
visibleJobs.map((job) => (
<div key={job.id} className={`rounded-xl border p-3 text-[13px] ${job.status === "failed" ? "border-danger-border bg-danger-bg" : "border-border bg-muted/50"}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
{job.status === "running" || job.status === "pending" ? <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" /> : job.status === "failed" ? <XCircle className="h-4 w-4 shrink-0 text-destructive" /> : <CheckCircle2 className="h-4 w-4 shrink-0 text-success" />}
<p className="truncate font-medium">{job.label ?? JOB_LABELS[job.type as keyof typeof JOB_LABELS]}</p>
</div>
{(job.status === "running" || job.status === "pending") && <Button size="sm" variant="ghost" className="h-7 shrink-0 px-2 text-[11px]" onClick={() => cancelJob(job.id)} disabled={cancellingId === job.id}>
{cancellingId === job.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
</Button>}
</div>
<div className="mt-2"><JobProgressPanel summary={job.progress} progressDetailRaw={job.progressDetail} compact jobType={progressType(job.type)} /></div>
{job.error && <p className="mt-2 text-xs text-destructive">{job.error}</p>}
{job.type === "style-8d" ? <Link href="/accounts" className="mt-2 inline-block text-[11px] text-muted-foreground underline"></Link> : job.topicId && <Link href={`/scans/${job.topicId}`} className="mt-2 inline-block text-[11px] text-muted-foreground underline"></Link>}
</div>
))
)}
</CardContent>
</Card>
)}
</div>
);
if (typeof document === "undefined") return panel;
return createPortal(panel, document.body);
}
return (
<Card className="mb-5 overflow-hidden border-primary/25 bg-primary/[0.035] shadow-[0_12px_35px_rgba(71,53,31,.06)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base"><Loader2 className="h-4 w-4 animate-spin text-primary" /></CardTitle>
<p className="text-[13px] text-muted-foreground">
</p>
</CardHeader>
<CardContent className="space-y-3">
{visibleJobs.map((job) => (
<div
key={job.id}
className="rounded-lg border border-border bg-muted/40 px-3.5 py-3 text-[13px]"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
{job.status === "running" || job.status === "pending" ? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> : job.status === "failed" ? <XCircle className="h-4 w-4 text-destructive" /> : <CheckCircle2 className="h-4 w-4 text-success" />}
<p className="font-medium">
{job.label ?? JOB_LABELS[job.type as keyof typeof JOB_LABELS]}
</p>
</div>
{(job.status === "running" || job.status === "pending") && <Button
size="sm"
variant="outline"
className="h-7 shrink-0 px-2 text-[11px]"
onClick={() => cancelJob(job.id)}
disabled={cancellingId === job.id}
>
{cancellingId === job.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<X className="h-3 w-3" />
)}
</Button>}
</div>
{job.error && <p className="mt-2 text-xs text-destructive">{job.error}</p>}
<div className="mt-2">
<JobProgressPanel
summary={job.progress}
progressDetailRaw={job.progressDetail}
compact
jobType={progressType(job.type)}
/>
</div>
{job.type === "style-8d" ? (
<Link href="/accounts" className="mt-2 inline-block text-[11px] underline text-muted-foreground">
</Link>
) : job.topicId && (
<Link
href={`/scans/${job.topicId}`}
className="mt-2 inline-block text-[11px] underline text-muted-foreground"
>
</Link>
)}
</div>
))}
</CardContent>
</Card>
);
}