383 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|