"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { JOB_LABELS, type JobStatus } from "@/lib/jobs/types"; import { notify } from "@/lib/notifications/store"; import { parseFetchJson } from "@/lib/utils"; export interface BackgroundJobItem { id: string; type: string; status: JobStatus; accountId?: string | null; topicId?: string | null; label?: string | null; progress?: string | null; progressDetail?: string | null; error?: string | null; result?: string | null; createdAt: string; completedAt?: string | null; } export interface LatestToast { title: string; message?: string; href?: string; type: "success" | "error" | "info" | "warning"; } interface JobsContextValue { jobs: BackgroundJobItem[]; activeJobs: BackgroundJobItem[]; latestToast: LatestToast | null; dismissToast: () => void; cancelJob: (jobId: string) => Promise; cancellingId: string | null; refreshJobs: () => Promise; } const JobsContext = createContext(null); function buildScanCompleteMessage( result: string | null | undefined, progress?: string | null ): string | undefined { if (progress && progress.startsWith("完成 ·")) return progress; try { const scan = JSON.parse(result ?? "{}") as { items?: unknown[]; repliesFetched?: boolean; repliesCount?: number; }; const total = scan.items?.length ?? 0; if (total === 0) return undefined; const replyPart = scan.repliesFetched ? `${scan.repliesCount ?? 0} 則留言 · ` : "未抓留言 · "; return `${replyPart}共 ${total} 篇`; } catch { return undefined; } } export function JobsProvider({ children }: { children: ReactNode }) { const [jobs, setJobs] = useState([]); const [latestToast, setLatestToast] = useState(null); const [cancellingId, setCancellingId] = useState(null); const notifiedRef = useRef>(new Set()); const initializedRef = useRef(false); const dismissToast = useCallback(() => setLatestToast(null), []); const refreshJobs = useCallback(async () => { let list: BackgroundJobItem[] = []; try { const res = await fetch("/api/jobs?limit=12"); const data = await parseFetchJson<{ jobs?: BackgroundJobItem[] }>(res); if (!res.ok) return; list = data.jobs ?? []; } catch { return; } setJobs(list); if (!initializedRef.current) { for (const job of list) { if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") { notifiedRef.current.add(job.id); } } initializedRef.current = true; return; } for (const job of list) { if ( (job.status === "completed" || job.status === "failed" || job.status === "cancelled") && !notifiedRef.current.has(job.id) ) { notifiedRef.current.add(job.id); const name = job.label ?? JOB_LABELS[job.type as keyof typeof JOB_LABELS] ?? job.type; window.dispatchEvent(new CustomEvent("job-completed", { detail: { job } })); if (job.status === "completed") { if (job.type === "analyze-topic") { const toast: LatestToast = { type: "success", title: `${name} 已完成`, href: job.topicId ? `/scans/${job.topicId}` : "/scans", }; setLatestToast(toast); notify(toast); } if (job.type === "scan") { const message = buildScanCompleteMessage(job.result, job.progress); const toast: LatestToast = { type: "success", title: "海巡完成", message, href: job.topicId ? `/scans/${job.topicId}/results` : "/scans", }; setLatestToast(toast); notify(toast); } if (job.type === "style-8d") { const toast: LatestToast = { type: "success", title: "帳號 8D 分析完成", message: job.progress ?? "8D 策略已儲存並套用", href: "/accounts", }; setLatestToast(toast); notify(toast); } if (job.type === "ai-task") { const toast: LatestToast = { type: "success", title: `${name}完成`, }; setLatestToast(toast); notify(toast); } } else if (job.status === "cancelled") { const toast: LatestToast = { type: "info", title: `${name} 已停止`, href: job.type === "style-8d" ? "/accounts" : job.topicId ? `/scans/${job.topicId}` : "/notifications", }; setLatestToast(toast); notify(toast); } else if (job.status === "failed") { const toast: LatestToast = { type: "error", title: `${name} 失敗`, message: job.error ?? "未知錯誤", href: job.type === "style-8d" ? "/accounts" : job.topicId ? `/scans/${job.topicId}` : "/notifications", }; setLatestToast(toast); notify(toast); } } } }, []); useEffect(() => { refreshJobs(); const timer = window.setInterval(refreshJobs, 2000); const refreshImmediately = () => void refreshJobs(); window.addEventListener("haixun:jobs-updated", refreshImmediately); return () => { window.clearInterval(timer); window.removeEventListener("haixun:jobs-updated", refreshImmediately); }; }, [refreshJobs]); useEffect(() => { if (!latestToast) return; const timer = window.setTimeout(() => setLatestToast(null), 8000); return () => window.clearTimeout(timer); }, [latestToast]); const cancelJob = useCallback( async (jobId: string) => { setCancellingId(jobId); try { const res = await fetch(`/api/jobs/${jobId}/cancel`, { method: "POST" }); const data = await res.json(); if (!res.ok) { notify({ type: "error", title: "停止失敗", message: data.error }); return false; } await refreshJobs(); return true; } finally { setCancellingId(null); } }, [refreshJobs] ); const activeJobs = useMemo( () => jobs.filter((j) => j.status === "pending" || j.status === "running"), [jobs] ); const value = useMemo( () => ({ jobs, activeJobs, latestToast, dismissToast, cancelJob, cancellingId, refreshJobs, }), [jobs, activeJobs, latestToast, dismissToast, cancelJob, cancellingId, refreshJobs] ); return {children}; } export function useJobs() { const ctx = useContext(JobsContext); if (!ctx) throw new Error("useJobs must be used within JobsProvider"); return ctx; }