Files
openmaic/OpenMAIC/components/classroom/generation-jobs-panel.tsx
T

383 lines
14 KiB
TypeScript

'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<string, string> = {
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<GenerationJobItem['status'], { label: string; className: string }> = {
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 (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${entry.className}`}
>
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{entry.label}
</span>
);
}
export function GenerationJobsPanel({ limit = 10 }: { limit?: number }) {
const { t } = useI18n();
const router = useRouter();
const showCourseTasks = isOpsWorkbenchEnabled();
const [jobs, setJobs] = useState<GenerationJobItem[]>([]);
const [courses, setCourses] = useState<CourseTaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-md p-4 space-y-3">
<div className="flex items-center gap-2">
<Hammer className="w-4 h-4 text-violet-500" />
<h2 className="text-sm font-semibold">{t('jobs.title')}</h2>
<span className="text-[11px] text-muted-foreground">{t('jobs.subtitle')}</span>
<Button
variant="ghost"
size="sm"
className="ml-auto h-6 px-2 text-[11px]"
onClick={() => void load()}
disabled={loading}
>
{t('publish.refresh')}
</Button>
</div>
{error && (
<p className="text-xs text-red-500">
{t('jobs.loadFailed')}: {error}
</p>
)}
{!loading && jobs.length === 0 && courses.length === 0 && (
<p className="text-xs text-muted-foreground py-2">{t('jobs.empty')}</p>
)}
{/* Large courses (large-course mode) — click into the course builder */}
{courses.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 pt-1">
<Layers className="w-3.5 h-3.5 text-violet-500" />
<span className="text-[11px] font-semibold text-muted-foreground">
{t('course.panelTitle')}
</span>
</div>
{courses.map((course) => (
<button
key={course.courseId}
onClick={() => router.push(`/courses/${course.courseId}`)}
className="w-full text-left rounded-xl border border-violet-200/60 dark:border-violet-900/50 bg-violet-50/40 dark:bg-violet-950/20 p-3 hover:border-violet-300 dark:hover:border-violet-700 hover:bg-violet-50/70 dark:hover:bg-violet-950/30 transition-all group"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
COURSE_STATUS_STYLES[course.status] ?? COURSE_STATUS_STYLES.queued
}`}
>
{course.status === 'generating' ||
course.status === 'framework_generating' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : null}
{t(`course.status.${course.status}`)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{course.status === 'framework_ready'
? t('course.awaitingConfirmHint')
: course.moduleCount > 0
? t('course.modulesProgress', {
succeeded: course.succeededCount,
total: course.moduleCount,
})
: null}
</span>
</div>
<p className="mt-1 text-sm font-medium truncate">{course.title}</p>
{course.error && (
<p className="text-[11px] text-red-500 truncate">{course.error}</p>
)}
</div>
<ChevronRight className="w-4 h-4 mt-1 shrink-0 text-violet-400/70 group-hover:text-violet-500 group-hover:translate-x-0.5 transition-all" />
</div>
</button>
))}
</div>
)}
<div className="space-y-2.5">
{jobs.map((job) => (
<div
key={job.jobId}
className="rounded-xl border border-border/50 bg-muted/30 dark:bg-zinc-950/40 p-3 space-y-2"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={job.status} />
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatTime(job.createdAt)}
</span>
{job.totalScenes !== undefined && job.totalScenes > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums">
{t('jobs.sceneProgress', {
done: job.scenesGenerated,
total: job.totalScenes,
})}
</span>
)}
</div>
<p className="mt-1.5 text-sm font-medium truncate">{job.requirement}</p>
<p className="text-[11px] text-muted-foreground truncate">
{job.status === 'failed' && job.error ? job.error : job.message}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{(job.status === 'queued' || job.status === 'running') && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[11px]"
disabled={busyId === job.jobId}
onClick={() => void cancelJob(job.jobId)}
>
<XCircle className="w-3 h-3" />
{t('jobs.cancel')}
</Button>
)}
{(job.status === 'cancelled' || job.status === 'failed') && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[11px]"
disabled={busyId === job.jobId}
onClick={() => void resumeJob(job.jobId)}
>
<Play className="w-3 h-3" />
{t('jobs.resume')}
</Button>
)}
{job.status === 'succeeded' && job.result && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[11px]"
onClick={() => router.push(`/classroom/${job.result!.classroomId}`)}
>
<ExternalLink className="w-3 h-3" />
{t('jobs.open')}
</Button>
)}
{job.status === 'failed' && <AlertTriangle className="w-3.5 h-3.5 text-red-500" />}
{job.status === 'cancelled' && <XCircle className="w-3.5 h-3.5 text-zinc-400" />}
{job.status === 'succeeded' && (
<CheckCircle2 className="w-3.5 h-3.5 text-green-500" />
)}
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-[11px] text-muted-foreground hover:text-red-500"
disabled={busyId === job.jobId}
onClick={() => void deleteJob(job.jobId)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
{job.status === 'running' && <Progress value={job.progress} className="h-1.5" />}
</div>
))}
</div>
</div>
);
}