haixunMaster/components/layout/jobs-provider.tsx

239 lines
6.9 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
"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<boolean>;
cancellingId: string | null;
refreshJobs: () => Promise<void>;
}
const JobsContext = createContext<JobsContextValue | null>(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<BackgroundJobItem[]>([]);
const [latestToast, setLatestToast] = useState<LatestToast | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const notifiedRef = useRef<Set<string>>(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 <JobsContext.Provider value={value}>{children}</JobsContext.Provider>;
}
export function useJobs() {
const ctx = useContext(JobsContext);
if (!ctx) throw new Error("useJobs must be used within JobsProvider");
return ctx;
}