Files
openmaic/OpenMAIC/app/learn/[coursewareId]/page.tsx
2026-08-16 14:58:47 +08:00

120 lines
4.7 KiB
TypeScript

'use client';
// Learner player entry — L3 user end.
//
// Resolves a published courseware from the registry, materializes (or reuses
// the cached copy of) its frozen bundle, then opens the classroom in pure
// playback. No course generation is ever triggered: the materialized document
// carries no generation outlines, so scene/media generation stays silent. The
// original classroom teacher/assistant, dynamic grading, and PBL runtime remain
// available and may use their normal teaching-time model calls.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { BookOpen, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/hooks/use-i18n';
import { loadLearnerCourseware } from '@/lib/bundle/learner-load';
type LoadPhase = 'resolving' | 'downloading' | 'opening' | 'error';
export default function LearnPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useI18n();
const coursewareId = String(params?.coursewareId ?? '');
// Large-course mode pass-through: when the learner entered through a course
// framework page, carry the course context into the classroom so it can show
// a "back to course" affordance.
const courseId = searchParams.get('course') ?? '';
const moduleIndex = searchParams.get('module') ?? '';
const courseVersion = searchParams.get('courseVersion') ?? '';
const versionParam = searchParams.get('version');
const expectedContentHash = searchParams.get('hash');
const [phase, setPhase] = useState<LoadPhase>('resolving');
const [error, setError] = useState<string | null>(null);
const startedRef = useRef(false);
const start = useCallback(async () => {
if (!coursewareId) return;
startedRef.current = true;
setPhase('resolving');
setError(null);
try {
const version = versionParam === null ? undefined : Number(versionParam);
const result = await loadLearnerCourseware(coursewareId, {
...(version !== undefined ? { version } : {}),
...(expectedContentHash !== null ? { expectedContentHash } : {}),
});
setPhase('opening');
const query = new URLSearchParams({ learner: '1', courseware: coursewareId });
if (courseId) query.set('course', courseId);
if (moduleIndex) query.set('module', moduleIndex);
if (courseVersion) query.set('courseVersion', courseVersion);
router.replace(`/classroom/${result.stageId}?${query.toString()}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPhase('error');
}
}, [
coursewareId,
courseId,
moduleIndex,
courseVersion,
versionParam,
expectedContentHash,
router,
]);
useEffect(() => {
if (startedRef.current || !coursewareId) return;
// Entry redirect must show its own loading state before the async load
// settles; the effect's synchronous call into `start` is intentional.
/* eslint-disable react-hooks/set-state-in-effect */
void start();
/* eslint-enable react-hooks/set-state-in-effect */
}, [coursewareId, start]);
return (
<main className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-zinc-950">
<div className="max-w-sm w-full px-6 text-center space-y-6">
<div className="mx-auto w-14 h-14 rounded-2xl bg-violet-100 dark:bg-violet-950/50 flex items-center justify-center">
<BookOpen className="w-7 h-7 text-violet-600 dark:text-violet-400" />
</div>
{phase === 'error' ? (
<>
<div>
<h1 className="text-lg font-semibold">{t('learner.loadFailed')}</h1>
<p className="mt-2 text-sm text-muted-foreground break-all">{error}</p>
</div>
<div className="flex items-center justify-center gap-2">
<Button variant="outline" onClick={() => router.push('/')}>
{t('learner.backHome')}
</Button>
<Button onClick={() => void start()}>
<RefreshCw className="w-4 h-4" />
{t('learner.retry')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{phase === 'downloading'
? t('learner.downloading')
: phase === 'opening'
? t('learner.opening')
: t('learner.resolving')}
</div>
<p className="text-xs text-muted-foreground/70">{t('learner.onceHint')}</p>
</>
)}
</div>
</main>
);
}