Files
openmaic/OpenMAIC/app/courses/[courseId]/page.tsx
T

665 lines
30 KiB
TypeScript

'use client';
// Ops large-course mode — course builder.
//
// Two-layer flow with an explicit confirmation gate:
// 1. Create → framework generates (framework_generating) → framework_ready.
// 2. Operator REVIEWS the framework, then confirms ("确认框架,开始逐模块生成")
// — only then the single-courseware agent generates each module one by one
// (generating), with live per-module progress from the classroom jobs.
// Actions: cancel / resume, regenerate the framework, regenerate one module
// plus its continuity-dependent tail, publish once every module is ready.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
ArrowLeft,
BookOpen,
CheckCircle2,
Copy,
ExternalLink,
Layers,
Loader2,
Play,
RefreshCw,
Rocket,
Sparkles,
Square,
XCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { useI18n } from '@/lib/hooks/use-i18n';
import type { CourseDetail } from '@/lib/course-framework/types';
import { toast } from 'sonner';
import { opsApiFetch } from '@/lib/ops/api-fetch';
const MODULE_STATUS_STYLES: Record<string, string> = {
pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
generating: 'bg-blue-100 text-blue-700 dark:bg-blue-950/60 dark:text-blue-300',
succeeded: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300',
};
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-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-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',
};
export default function CourseBuilderPage() {
const params = useParams();
const router = useRouter();
const { t } = useI18n();
const courseId = String(params?.courseId ?? '');
const [course, setCourse] = useState<CourseDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
if (!courseId) return;
try {
const response = await opsApiFetch(`/api/courses/${courseId}`);
const body = (await response.json()) as { course?: CourseDetail };
if (!response.ok || !body.course) {
throw new Error(`HTTP ${response.status}`);
}
setCourse(body.course);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [courseId]);
useEffect(() => {
void load();
const timer = setInterval(() => void load(), 3000);
return () => clearInterval(timer);
}, [load]);
const actingRef = useRef(false);
const act = async (action: string, fn: () => Promise<Response>) => {
if (actingRef.current) return;
actingRef.current = true;
setActing(action);
try {
const response = await fn();
const body = (await response.json()) as { error?: string; details?: string };
if (!response.ok) {
toast.error(body.details ?? body.error ?? `HTTP ${response.status}`);
} else {
toast.success(t(`course.actionDone.${action}`));
}
await load();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
actingRef.current = false;
setActing(null);
}
};
const publishCourse = async () => {
await act('publish', () =>
opsApiFetch(`/api/courses/${courseId}/publish`, {
method: 'POST',
}),
);
};
const copyLearnerLink = async () => {
try {
await navigator.clipboard.writeText(`${window.location.origin}/learn/course/${courseId}`);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const allGenerated =
!!course && course.counts.total > 0 && course.counts.succeeded === course.counts.total;
const allPublished = !!course && course.counts.published === course.counts.total;
const busy =
!!course &&
(course.status === 'generating' ||
course.status === 'framework_generating' ||
course.status === 'queued');
const formatTime = (iso?: string) =>
iso ? new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
if (loading && !course) {
return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</main>
);
}
if (error && !course) {
return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
<Button variant="outline" size="sm" onClick={() => router.push('/courses')}>
<ArrowLeft className="w-4 h-4" />
{t('common.back')}
</Button>
</div>
</main>
);
}
if (!course) return null;
const progressPercent =
course.counts.total > 0 ? Math.round((course.counts.succeeded / course.counts.total) * 100) : 0;
const generatingModule = course.modules.find((m) => m.status === 'generating');
return (
<main className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<div className="mx-auto max-w-4xl px-6 py-10">
{/* Header */}
<header className="flex items-start justify-between gap-4 mb-6">
<div>
<button
onClick={() => router.push('/courses')}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors mb-2"
>
<ArrowLeft className="w-3.5 h-3.5" />
{t('course.backToList')}
</button>
<h1 className="flex items-center gap-2 text-xl font-semibold">
<Layers className="w-5 h-5 text-violet-500" />
{course.frameworkTitle ?? course.requirement.slice(0, 40)}
<Badge className={COURSE_STATUS_STYLES[course.status] ?? COURSE_STATUS_STYLES.queued}>
{t(`course.status.${course.status}`)}
</Badge>
</h1>
<p className="text-xs text-muted-foreground mt-1">
{course.id} · {t('course.createdAt')} {formatTime(course.createdAt)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{busy && (
<Button
variant="outline"
size="sm"
disabled={acting !== null}
onClick={() =>
void act('cancel', () =>
opsApiFetch(`/api/courses/${courseId}/cancel`, { method: 'POST' }),
)
}
>
<Square className="w-4 h-4" />
{t('course.cancelAction')}
</Button>
)}
{!busy && course.status !== 'completed' && course.status !== 'framework_ready' && (
<Button
variant="outline"
size="sm"
disabled={acting !== null}
onClick={() =>
void act('resume', () =>
opsApiFetch(`/api/courses/${courseId}/resume`, { method: 'POST' }),
)
}
>
<Play className="w-4 h-4" />
{t('course.resumeAction')}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={() => void copyLearnerLink()}>
<Copy className="w-4 h-4" />
{copied ? t('course.copied') : t('course.learnerLink')}
</Button>
<Button
size="sm"
disabled={!allGenerated || acting !== null || allPublished}
onClick={() => void publishCourse()}
>
<Rocket className="w-4 h-4" />
{t('course.publishAction')}
</Button>
</div>
</header>
{course.error && (
<div className="rounded-lg border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200 mb-4">
{course.error}
</div>
)}
{/* Framework generating banner */}
{course.status === 'framework_generating' && (
<section className="rounded-2xl border border-blue-200/70 dark:border-blue-900/50 bg-blue-50/70 dark:bg-blue-950/30 p-5 mb-6 flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-blue-500" />
<div>
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
{t('course.frameworkGenerating')}
</p>
<p className="text-xs text-blue-700/70 dark:text-blue-300/70 mt-0.5">
{t('course.frameworkGeneratingHint')}
</p>
</div>
</section>
)}
{/* Framework review — the confirmation gate before module generation */}
{course.status === 'framework_ready' && course.framework && (
<section className="rounded-2xl border border-amber-200/80 dark:border-amber-900/50 bg-gradient-to-br from-amber-50/80 to-white dark:from-amber-950/25 dark:to-zinc-900 p-5 mb-6 shadow-sm">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-amber-500" />
<h2 className="text-sm font-semibold">{t('course.frameworkReviewTitle')}</h2>
</div>
<p className="text-xs text-muted-foreground mt-1">{t('course.frameworkReviewHint')}</p>
<p className="mt-3 text-[13px] text-foreground/90 leading-relaxed">
{course.framework.summary}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-amber-200/70 dark:border-amber-900/50 bg-white/70 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] font-medium text-amber-800 dark:text-amber-200">
{t('course.targetAudience')}
</p>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{course.framework.targetAudience}
</p>
</div>
<div className="rounded-lg border border-amber-200/70 dark:border-amber-900/50 bg-white/70 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] font-medium text-amber-800 dark:text-amber-200">
{t('course.languageDirectiveLabel')}
</p>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{course.framework.languageDirective}
</p>
</div>
</div>
<div className="mt-3 rounded-lg border border-amber-200/70 dark:border-amber-900/50 bg-white/70 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] font-medium text-amber-800 dark:text-amber-200">
{t('course.courseGoals')}
</p>
<ul className="mt-1.5 space-y-1 text-xs text-muted-foreground">
{course.framework.courseGoals.map((goal) => (
<li key={goal} className="flex gap-2">
<span aria-hidden="true">•</span>
<span>{goal}</span>
</li>
))}
</ul>
</div>
<details className="mt-3 rounded-lg border border-amber-200/70 dark:border-amber-900/50 bg-white/70 dark:bg-zinc-950/30 p-3">
<summary className="cursor-pointer text-xs font-medium text-amber-800 dark:text-amber-200">
{t('course.continuityContract')}
</summary>
<dl className="mt-3 grid gap-3 text-xs sm:grid-cols-2">
<div>
<dt className="font-medium">{t('course.terminology')}</dt>
<dd className="mt-1 text-muted-foreground leading-relaxed">
{course.framework.continuityContract.terminology.join(' · ')}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.teachingStyle')}</dt>
<dd className="mt-1 text-muted-foreground leading-relaxed">
{course.framework.continuityContract.teachingStyle}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.difficultyProgression')}</dt>
<dd className="mt-1 text-muted-foreground leading-relaxed">
{course.framework.continuityContract.difficultyProgression}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.assessmentStrategy')}</dt>
<dd className="mt-1 text-muted-foreground leading-relaxed">
{course.framework.continuityContract.assessmentStrategy}
</dd>
</div>
</dl>
</details>
<ol className="mt-3 space-y-2">
{course.framework.modules.map((module) => (
<li
key={module.index}
className="rounded-lg border border-amber-200/70 dark:border-amber-900/50 bg-white/70 dark:bg-zinc-950/30 p-3 text-[13px]"
>
<div className="flex items-start gap-2">
<span className="inline-flex items-center justify-center size-5 rounded-full bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 text-[11px] font-semibold shrink-0 mt-0.5">
{module.index}
</span>
<div className="min-w-0 flex-1">
<span className="font-medium">{module.title}</span>
{module.estimatedMinutes ? (
<span className="text-[11px] text-muted-foreground ml-1.5">
· ~{module.estimatedMinutes} min
</span>
) : null}
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
{module.description}
</p>
</div>
</div>
<details className="mt-2 ml-7">
<summary className="cursor-pointer text-[11px] font-medium text-amber-800 dark:text-amber-200">
{t('course.moduleGenerationDetails')}
</summary>
<div className="mt-2 space-y-3 text-xs">
<div>
<p className="font-medium">{t('course.learningObjectives')}</p>
<ul className="mt-1 space-y-1 text-muted-foreground">
{module.learningObjectives.map((objective) => (
<li key={objective} className="flex gap-2">
<span aria-hidden="true">•</span>
<span>{objective}</span>
</li>
))}
</ul>
</div>
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="font-medium">{t('course.prerequisites')}</dt>
<dd className="mt-0.5 text-muted-foreground">
{module.prerequisites || t('course.none')}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.incomingKnowledge')}</dt>
<dd className="mt-0.5 text-muted-foreground">
{module.incomingKnowledge.join(' · ') || t('course.none')}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.outgoingKnowledge')}</dt>
<dd className="mt-0.5 text-muted-foreground">
{module.outgoingKnowledge.join(' · ')}
</dd>
</div>
<div>
<dt className="font-medium">{t('course.excludedTopics')}</dt>
<dd className="mt-0.5 text-muted-foreground">
{module.excludedTopics.join(' · ') || t('course.none')}
</dd>
</div>
</dl>
<div>
<p className="font-medium">{t('course.generationPrompt')}</p>
<p className="mt-1 rounded-md bg-amber-50 dark:bg-amber-950/30 p-2 text-muted-foreground leading-relaxed whitespace-pre-wrap">
{module.generationPrompt}
</p>
</div>
</div>
</details>
</li>
))}
</ol>
<div className="mt-4 flex items-center gap-2 flex-wrap">
<Button
size="sm"
disabled={acting !== null}
onClick={() =>
void act('start', () =>
opsApiFetch(`/api/courses/${courseId}/start`, { method: 'POST' }),
)
}
>
<Play className="w-4 h-4" />
{t('course.startModulesAction')}
</Button>
<Button
variant="outline"
size="sm"
disabled={acting !== null}
onClick={() =>
void act('frameworkRegenerate', () =>
opsApiFetch(`/api/courses/${courseId}/framework/regenerate`, { method: 'POST' }),
)
}
>
<RefreshCw className="w-4 h-4" />
{t('course.regenerateFrameworkAction')}
</Button>
<span className="text-[11px] text-muted-foreground ml-1">
{t('course.confirmCostHint', { count: course.framework.modules.length })}
</span>
</div>
</section>
)}
{/* Progress */}
{(course.status === 'generating' ||
course.status === 'completed' ||
course.status === 'failed' ||
course.status === 'cancelled') && (
<section className="rounded-2xl border border-border/60 bg-white dark:bg-zinc-900 p-5 shadow-sm mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{t('course.progressTitle')}</span>
<span className="text-xs tabular-nums text-muted-foreground">
{course.counts.succeeded}/{course.counts.total} ·{' '}
{t('course.publishedCount', { count: course.counts.published })}
</span>
</div>
<Progress value={progressPercent} className="h-2" />
{generatingModule && (
<div className="mt-3 rounded-lg bg-blue-50/70 dark:bg-blue-950/30 border border-blue-200/60 dark:border-blue-900/50 p-3">
<div className="flex items-center gap-2 text-xs text-blue-800 dark:text-blue-200">
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
<span className="font-medium">
{t('course.moduleGenerating', {
index: generatingModule.index,
title: generatingModule.title,
})}
</span>
{generatingModule.totalScenes !== undefined &&
generatingModule.totalScenes > 0 && (
<span className="tabular-nums text-blue-700/80 dark:text-blue-300/80">
{t('course.jobSceneProgress', {
done: generatingModule.scenesGenerated ?? 0,
total: generatingModule.totalScenes,
})}
</span>
)}
</div>
{generatingModule.jobMessage && (
<p className="mt-1 text-[11px] text-blue-700/70 dark:text-blue-300/70 truncate">
{generatingModule.jobMessage}
</p>
)}
<Progress value={generatingModule.jobProgress ?? 0} className="h-1.5 mt-2" />
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">{t('course.progressHint')}</p>
</section>
)}
{/* Framework info (post-confirmation summary) */}
{course.framework && course.status !== 'framework_ready' && (
<section className="rounded-2xl border border-border/60 bg-white dark:bg-zinc-900 p-5 shadow-sm mb-6">
<h2 className="text-sm font-medium mb-2">{t('course.frameworkTitle')}</h2>
<p className="text-[13px] text-foreground/90 leading-relaxed">
{course.framework.summary}
</p>
<details className="mt-2">
<summary className="text-xs text-muted-foreground cursor-pointer">
{t('course.languageDirective')}
</summary>
<p className="mt-2 text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
{course.framework.languageDirective}
</p>
</details>
</section>
)}
{/* Modules */}
<section className="space-y-3">
<h2 className="text-sm font-medium">{t('course.modulesTitle')}</h2>
{course.modules.length === 0 && (
<p className="text-sm text-muted-foreground py-6 text-center">
{t('course.noModules')}
</p>
)}
{course.modules.map((module) => (
<div
key={module.index}
className="rounded-xl border border-border/60 bg-white dark:bg-zinc-900 p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center justify-center size-5 rounded-full bg-violet-100 dark:bg-violet-950/60 text-violet-700 dark:text-violet-300 text-[11px] font-semibold shrink-0">
{module.index}
</span>
<h3 className="font-medium text-sm">{module.title}</h3>
<Badge
className={
MODULE_STATUS_STYLES[module.status] ?? MODULE_STATUS_STYLES.pending
}
>
{t(`course.moduleStatus.${module.status}`)}
</Badge>
{module.published && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
{t('course.publishedBadge')}
</Badge>
)}
</div>
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">
{module.description}
</p>
{module.outputDigest && (
<details className="mt-2 rounded-lg border border-border/50 bg-zinc-50/70 dark:bg-zinc-950/40 p-2.5">
<summary className="cursor-pointer text-[11px] font-medium text-foreground/80">
{t('course.actualOutput')} · {module.outputDigest.sceneCount}{' '}
{t('course.scenesUnit')}
</summary>
<div className="mt-2 space-y-2 text-[11px] text-muted-foreground">
<p className="leading-relaxed">{module.outputDigest.summary}</p>
<p className="font-mono break-all">
{t('course.semanticHash')}: {module.outputDigest.semanticHash}
</p>
{module.outputDigest.coveredConcepts.length > 0 && (
<p>
<span className="font-medium text-foreground/75">
{t('course.actualCoverage')}:{' '}
</span>
{module.outputDigest.coveredConcepts.slice(0, 12).join(' · ')}
</p>
)}
{module.outputDigest.interactiveExperiences.length > 0 && (
<p>
<span className="font-medium text-foreground/75">
{t('course.actualInteractions')}:{' '}
</span>
{module.outputDigest.interactiveExperiences.join(' · ')}
</p>
)}
{module.outputDigest.assessments.length > 0 && (
<p>
<span className="font-medium text-foreground/75">
{t('course.actualAssessments')}:{' '}
</span>
{module.outputDigest.assessments.join(' · ')}
</p>
)}
</div>
</details>
)}
{module.status === 'generating' && (
<div className="mt-2 max-w-md">
{module.totalScenes !== undefined && module.totalScenes > 0 ? (
<p className="text-[11px] text-blue-700/80 dark:text-blue-300/80 tabular-nums">
{t('course.jobSceneProgress', {
done: module.scenesGenerated ?? 0,
total: module.totalScenes,
})}
</p>
) : null}
{module.jobMessage && (
<p className="text-[11px] text-muted-foreground truncate">
{module.jobMessage}
</p>
)}
<Progress value={module.jobProgress ?? 0} className="h-1.5 mt-1.5" />
</div>
)}
{module.error && (
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400 break-all">
{module.error}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{module.status === 'succeeded' && module.classroomId && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/classroom/${module.classroomId}`)}
>
<BookOpen className="w-4 h-4" />
{t('course.openClassroom')}
</Button>
)}
{module.published && module.publishedCoursewareId && (
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/learn/${module.publishedCoursewareId}`)}
>
<ExternalLink className="w-4 h-4" />
{t('course.previewLearner')}
</Button>
)}
{(module.status === 'failed' || module.status === 'succeeded') && (
<Button
variant="outline"
size="sm"
disabled={acting !== null || busy}
title={t('course.regenerateModuleCascadeHint')}
onClick={() => {
if (!window.confirm(t('course.regenerateModuleCascadeHint'))) return;
void act('regenerate', () =>
opsApiFetch(`/api/courses/${courseId}/modules/${module.index}/regenerate`, {
method: 'POST',
}),
);
}}
>
<RefreshCw className="w-4 h-4" />
{t('course.regenerateModule')}
</Button>
)}
{module.status === 'generating' && (
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
)}
{module.status === 'succeeded' && (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
)}
{module.status === 'failed' && <XCircle className="w-4 h-4 text-red-500" />}
</div>
</div>
</div>
))}
</section>
</div>
</main>
);
}