665 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|