239 lines
6.9 KiB
TypeScript
239 lines
6.9 KiB
TypeScript
|
|
"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;
|
||
|
|
}
|