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

178 lines
9.1 KiB
TypeScript
Raw 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 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>
);
}