'use client'; // Background generation task panel — the "后台生成" control center. // // Lists server-side generation jobs (POST /api/generate-classroom), polls // active ones, and offers per-job actions: // - cancel stop a running/queued job (keeps nothing; server-side // generation commits atomically, so a cancelled job produces // no classroom) // - delete cancel (if needed) and remove the job + any classroom it // produced // - open jump to a succeeded job's classroom // // The panel works even after the generation page is closed — jobs run on the // server, independent of any browser tab. import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AlertTriangle, CheckCircle2, ChevronRight, ExternalLink, Hammer, Layers, Loader2, Play, Trash2, XCircle, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { isOpsWorkbenchEnabled } from '@/lib/config/deployment-role'; import { useI18n } from '@/lib/hooks/use-i18n'; export interface GenerationJobItem { jobId: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; step: string; progress: number; message: string; createdAt: string; requirement: string; scenesGenerated: number; totalScenes?: number; result?: { classroomId: string; url: string; scenesCount: number }; error?: string; done: boolean; } /** Large-course (large-course mode) summary shown in the task panel. */ export interface CourseTaskItem { courseId: string; status: | 'queued' | 'framework_generating' | 'framework_ready' | 'generating' | 'completed' | 'failed' | 'cancelled'; title: string; moduleCount: number; succeededCount: number; error?: string; createdAt: string; updatedAt: string; } const COURSE_STATUS_STYLES: Record = { queued: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300', framework_generating: 'bg-blue-100 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300', framework_ready: 'bg-amber-100 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300', generating: 'bg-blue-100 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300', completed: 'bg-green-100 text-green-700 dark:bg-green-950/60 dark:text-green-300', failed: 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300', cancelled: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400', }; const ACTIVE_POLL_MS = 4000; function StatusBadge({ status }: { status: GenerationJobItem['status'] }) { const { t } = useI18n(); const map: Record = { queued: { label: t('jobs.statusQueued'), className: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300', }, running: { label: t('jobs.statusRunning'), className: 'bg-violet-100 text-violet-700 dark:bg-violet-950/60 dark:text-violet-300', }, succeeded: { label: t('jobs.statusSucceeded'), className: 'bg-green-100 text-green-700 dark:bg-green-950/60 dark:text-green-300', }, failed: { label: t('jobs.statusFailed'), className: 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300', }, cancelled: { label: t('jobs.statusCancelled'), className: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400', }, }; const entry = map[status]; return ( {status === 'running' && } {entry.label} ); } export function GenerationJobsPanel({ limit = 10 }: { limit?: number }) { const { t } = useI18n(); const router = useRouter(); const showCourseTasks = isOpsWorkbenchEnabled(); const [jobs, setJobs] = useState([]); const [courses, setCourses] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); const timerRef = useRef | null>(null); const load = useCallback(async () => { try { const jobsResponse = await fetch(`/api/generate-classroom?limit=${limit}`); const jobsBody = (await jobsResponse.json()) as { items?: GenerationJobItem[] }; if (!jobsResponse.ok || !Array.isArray(jobsBody.items)) { throw new Error('HTTP error'); } setJobs(jobsBody.items); setError(null); // Large-course authoring belongs exclusively to the operations surface. // Learner deployments must not probe the privileged endpoint at all. Keep // this request isolated from single-courseware jobs so an ops auth failure // cannot hide otherwise valid background generation tasks. if (!showCourseTasks) { setCourses([]); return; } try { const coursesResponse = await fetch('/api/courses?limit=5'); const coursesBody = (await coursesResponse.json()) as { items?: CourseTaskItem[] }; setCourses(coursesResponse.ok && Array.isArray(coursesBody.items) ? coursesBody.items : []); } catch { setCourses([]); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } }, [limit, showCourseTasks]); // Poll only while jobs are active; stop when everything settles. useEffect(() => { void load(); const tick = () => { setJobs((current) => { const active = current.some((j) => j.status === 'queued' || j.status === 'running'); if (active) void load(); return current; }); }; timerRef.current = setInterval(tick, ACTIVE_POLL_MS); return () => { if (timerRef.current) clearInterval(timerRef.current); }; }, [load]); const cancelJob = async (jobId: string) => { setBusyId(jobId); try { await fetch(`/api/generate-classroom/${jobId}`, { method: 'POST' }); await load(); } finally { setBusyId(null); } }; const resumeJob = async (jobId: string) => { setBusyId(jobId); try { await fetch(`/api/generate-classroom/${jobId}/resume`, { method: 'POST' }); await load(); } finally { setBusyId(null); } }; const deleteJob = async (jobId: string) => { if (!window.confirm(t('jobs.deleteConfirm'))) return; setBusyId(jobId); try { await fetch(`/api/generate-classroom/${jobId}`, { method: 'DELETE' }); await load(); } finally { setBusyId(null); } }; const formatTime = (iso: string) => new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); return (

{t('jobs.title')}

{t('jobs.subtitle')}
{error && (

{t('jobs.loadFailed')}: {error}

)} {!loading && jobs.length === 0 && courses.length === 0 && (

{t('jobs.empty')}

)} {/* Large courses (large-course mode) — click into the course builder */} {courses.length > 0 && (
{t('course.panelTitle')}
{courses.map((course) => ( ))}
)}
{jobs.map((job) => (
{formatTime(job.createdAt)} {job.totalScenes !== undefined && job.totalScenes > 0 && ( {t('jobs.sceneProgress', { done: job.scenesGenerated, total: job.totalScenes, })} )}

{job.requirement}

{job.status === 'failed' && job.error ? job.error : job.message}

{(job.status === 'queued' || job.status === 'running') && ( )} {(job.status === 'cancelled' || job.status === 'failed') && ( )} {job.status === 'succeeded' && job.result && ( )} {job.status === 'failed' && } {job.status === 'cancelled' && } {job.status === 'succeeded' && ( )}
{job.status === 'running' && }
))}
); }