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