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