Files
2026-08-16 14:58:47 +08:00

523 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* PBL v2 — Hero page.
*
* The Hero is the first thing the learner sees when they flip to the
* PBL scene. It's deliberately light on information — title, short
* description, the skills they will learn, the rough shape (stage /
* task counts), and a single big "开始项目" call to action. Detail
* (microtask list, hints, scripts) lives in the Workspace; opening
* with all of that on screen would just intimidate.
*
* Clicking 开始项目 enters the Workspace immediately. The chat panel
* then fires the GREETING phase of the Instructor (`/api/pbl/v2/open-task`)
* so the opener streams in the place where the learner will read it.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
ArrowRight,
CheckCircle2,
Drama,
GraduationCap,
Layers,
RotateCcw,
Sparkles,
Target,
} from 'lucide-react';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import { trimmedPBLText } from '@/lib/pbl/v2/readers';
import { useStageStore } from '@/lib/store/stage';
import { buildQuizSnapshot } from '@/lib/pbl/v2/operations/runtime/quiz-snapshot';
import { hasStartedProject, resetProjectProgress } from '@/lib/pbl/v2/operations/kernel/progress';
import { transitionProjectUiPhase } from '@/lib/pbl/v2/operations/kernel/runtime-events';
import {
invalidatePendingWorkspaceLaunch,
isCurrentWorkspaceLaunch,
prepareCurrentWorkspaceLaunchProject,
} from '@/lib/pbl/v2/operations/runtime/workspace-launch';
import { useI18n } from '@/lib/hooks/use-i18n';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { computeFitScale } from './fit-scale';
const LAUNCH_BUTTON_CLASS =
'group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-primary py-4 text-base font-semibold text-primary-foreground shadow-[0_14px_34px_-12px_hsl(var(--primary)/0.7)] ring-1 ring-inset ring-white/15 transition-all duration-300 hover:-translate-y-0.5 hover:shadow-[0_20px_44px_-12px_hsl(var(--primary)/0.85)] disabled:translate-y-0 disabled:opacity-70';
interface Props {
readonly sceneId: string;
readonly project: PBLProjectV2;
readonly onProjectChange: (next: PBLProjectV2) => void;
readonly onLaunchReady?: (next: PBLProjectV2) => void;
/** True while a workspace stream started earlier is still in flight in the
* background (the learner stepped back to the Hero mid-stream). Reset is
* disabled while true: a late stream write would otherwise land on top of —
* and undo — the reset. */
readonly instructorStreaming?: boolean;
}
export function PBLV2Hero({
sceneId,
project,
onProjectChange,
onLaunchReady,
instructorStreaming = false,
}: Props) {
const { t, locale } = useI18n();
// Sync `project.language` (BCP-47 fallback locale) with the user's
// UI locale from the top-right language switcher. This ONLY updates
// the BCP-47 fallback — `project.languageDirective` (set by the
// Planner from the classroom's content-language policy) is the
// authoritative content-language source and is never overwritten
// here. Runs once on mount per scene; cheap idempotent.
useEffect(() => {
if (!locale) return;
if (project.language === locale) return;
onProjectChange({ ...project, language: locale });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sceneId, locale]);
const instructorRole = useMemo(
() => project.roles.find((r) => r.type === 'instructor'),
[project.roles],
);
const microtaskCount = useMemo(
() => project.milestones.reduce((acc, m) => acc + m.microtasks.length, 0),
[project.milestones],
);
// SCENARIO ONLY. How many roleplay "acts" sit between prep and wrapup (≥1).
const roleplayActCount = useMemo(
() => project.milestones.filter((m) => m.scenarioStage === 'roleplay').length,
[project.milestones],
);
const [launching, setLaunching] = useState(false);
const launchEpochRef = useRef(0);
const currentSceneIdRef = useRef(sceneId);
const currentProjectRef = useRef(project);
currentSceneIdRef.current = sceneId;
currentProjectRef.current = project;
useEffect(() => {
invalidatePendingWorkspaceLaunch(launchEpochRef, setLaunching);
return () => {
launchEpochRef.current += 1;
};
}, [sceneId]);
const handleStart = async () => {
// Build a snapshot of the learner's prior-quiz results from
// RuntimeStore so the server can fold quiz-accuracy into the
// proficiency assessment before the Instructor's GREETING fires
// from the Workspace chat.
// Scenes from the same classroom that come BEFORE this PBL
// contribute; anything after (or this scene itself) is ignored.
//
const epoch = ++launchEpochRef.current;
const launchSceneId = sceneId;
setLaunching(true);
const allScenes = useStageStore.getState().scenes;
const selfIdx = allScenes.findIndex((s) => s.id === sceneId);
const priorScenes = selfIdx >= 0 ? allScenes.slice(0, selfIdx) : [];
try {
const priorQuizResults = await buildQuizSnapshot(priorScenes);
if (!isCurrentWorkspaceLaunch(epoch, launchEpochRef, launchSceneId, currentSceneIdRef))
return;
const ready = prepareCurrentWorkspaceLaunchProject(currentProjectRef, priorQuizResults);
if (onLaunchReady) onLaunchReady(ready);
else onProjectChange(ready);
} finally {
if (epoch === launchEpochRef.current) setLaunching(false);
}
};
// Resume an already-started project: no GREETING (that would duplicate
// the opener and is unnecessary), just re-enter the workspace with all
// progress intact. Reuse the launch path so the 1.3s reveal plays, same
// as a first entry.
const handleContinue = () => {
const ready = transitionProjectUiPhase(project, 'workspace');
if (onLaunchReady) onLaunchReady(ready);
else onProjectChange(ready);
};
// Wipe progress back to a brand-new, never-played project. Stays on the
// Hero (`uiPhase: 'hero'`); the button flips back to "Start project" and
// the next launch replays the full first-time journey.
const handleReset = () => {
onProjectChange(resetProjectProgress(project));
};
const started = hasStartedProject(project);
// "What you'll gain" — the Planner's structured `gains` (3-5 readable,
// project-specific competency statements: abilities / awareness /
// knowledge built through the project). Fall back to the single
// `learningObjective` sentence for legacy projects packaged before
// `gains` existed; hide entirely when neither is set. We never
// back-fill from task titles — that produced unreadable fragments
// ("架构可行", "场景清晰") that aren't capabilities.
const plannerGains = (project.gains ?? []).map(trimmedPBLText).filter(Boolean);
const learningObjective = trimmedPBLText(project.learningObjective);
const gains =
plannerGains.length > 0 ? plannerGains : learningObjective ? [learningObjective] : [];
// SCENARIO ONLY. `project.scenario` is the single gate for role-play
// projects; normal projects leave it undefined and show only the
// Instructor. We surface the primary in-scene character so the learner
// sees, up front, who they'll be interacting with — distinct from the
// Instructor mentor. The subtitle is a short fixed tagline (mirroring
// the Instructor's), NOT the character's `situation`, which is too long
// for the card and gets clipped. Display-only: never gates any logic.
const scenarioCharacter = project.scenario?.characters?.[0];
// Fit-to-box scaling. The Hero renders as flowed DOM inside the stage's
// fixed 16:9 box (`overflow-hidden`, no scroll). At higher browser zoom the
// box gets shorter than the card's natural height and the bottom (launch
// button + hint) is clipped with no way to reach it — unlike a slide deck,
// which stays fully visible by scaling to fit. Mirror that: measure the box
// and the card and shrink the card uniformly when it would overflow. Layout
// size (`offsetWidth/Height`) is unaffected by the transform, so observing
// it can't feedback-loop with the scale we apply.
const fitContainerRef = useRef<HTMLDivElement>(null);
const fitContentRef = useRef<HTMLDivElement>(null);
const [fitScale, setFitScale] = useState(1);
useLayoutEffect(() => {
const container = fitContainerRef.current;
const content = fitContentRef.current;
if (!container || !content) return;
const measure = () => {
const next = computeFitScale({
containerWidth: container.clientWidth,
containerHeight: container.clientHeight,
contentWidth: content.offsetWidth,
contentHeight: content.offsetHeight,
});
setFitScale((prev) => (Math.abs(prev - next) < 0.002 ? prev : next));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
observer.observe(content);
return () => observer.disconnect();
}, []);
return (
<div
ref={fitContainerRef}
className="relative h-full w-full overflow-hidden bg-gradient-to-br from-background via-background to-primary/[0.14]"
>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 bg-[linear-gradient(90deg,hsl(var(--foreground)/0.035)_1px,transparent_1px),linear-gradient(180deg,hsl(var(--foreground)/0.035)_1px,transparent_1px)] bg-[size:46px_46px] [mask-image:radial-gradient(ellipse_at_center,black_45%,transparent_92%)]" />
<div className="absolute -left-[8%] -top-[14%] h-[66%] w-[52%] rounded-full bg-[radial-gradient(circle,hsl(var(--primary)/0.34),transparent_70%)] blur-3xl" />
<div className="absolute -bottom-[18%] -right-[8%] h-[72%] w-[58%] rounded-full bg-[radial-gradient(circle,hsl(var(--primary)/0.30),transparent_72%)] blur-3xl" />
<div className="absolute left-1/2 top-1/2 h-[80%] w-[66%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(circle,hsl(var(--primary)/0.12),transparent_72%)] blur-2xl" />
</div>
{/* Centering layer: `items-center` (not the flex default `stretch`) so the
card keeps its NATURAL height — otherwise it would be stretched to the
box and `offsetHeight` would equal the container, defeating the fit
measurement below. */}
<div className="absolute inset-0 flex items-center justify-center">
<div
ref={fitContentRef}
className="relative z-10 w-full max-w-3xl px-8 py-10"
style={
fitScale < 1
? { transform: `scale(${fitScale})`, transformOrigin: 'center center' }
: undefined
}
>
<div className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-primary shadow-sm shadow-primary/10">
<Sparkles className="h-3.5 w-3.5" />
{t('pbl.v2.hero.title')}
</div>
<h1 className="bg-gradient-to-br from-foreground via-foreground to-primary bg-clip-text pb-1 text-[2.6rem] font-extrabold leading-[1.12] tracking-tight text-transparent">
{project.title}
</h1>
<p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-muted-foreground">
{project.description}
</p>
{gains.length > 0 && (
<div className="mt-6">
<div className="mb-2.5 flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
<Target className="h-3 w-3" />
{t('pbl.v2.hero.youWillLearn')}
</div>
<ul
className={
gains.length > 1
? 'grid grid-cols-1 gap-2 sm:grid-cols-2'
: 'grid grid-cols-1 gap-2'
}
>
{gains.map((gain, i) => (
<li
key={i}
className="flex items-start gap-2 rounded-xl bg-foreground/[0.03] px-3 py-2 ring-1 ring-inset ring-foreground/[0.07] transition-colors hover:bg-primary/[0.05] hover:ring-primary/20"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span className="text-[13px] leading-snug text-foreground/90">{gain}</span>
</li>
))}
</ul>
</div>
)}
{/* Stage / task counts + people. Narrow boxes use two columns to
avoid grid min-width overflow; wider boxes keep the single-row
layout that balances the Hero. */}
<div
className={
scenarioCharacter
? // SCENARIO: replace the mechanical "stage / task" counts with
// the meaningful three-act flow card + the two people the
// learner works with (Instructor + role-play character).
'mt-6 grid grid-cols-1 gap-2.5 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)]'
: 'mt-6 grid grid-cols-2 gap-2.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.6fr)]'
}
>
{scenarioCharacter ? (
<ScenarioFlowCard
label={t('pbl.v2.hero.scenarioFlow')}
prep={t('pbl.v2.sidebar.stagePrep')}
roleplay={t('pbl.v2.sidebar.stageRoleplay')}
wrapup={t('pbl.v2.sidebar.stageWrapup')}
roleplayCount={roleplayActCount}
/>
) : (
<>
<StatCard
icon={<Layers className="h-4 w-4" />}
value={project.milestones.length}
label={t('pbl.v2.hero.stage')}
/>
<StatCard
icon={<Target className="h-4 w-4" />}
value={microtaskCount}
label={t('pbl.v2.hero.task')}
/>
</>
)}
<PersonCard
icon={<GraduationCap className="h-4 w-4" />}
role={t('pbl.v2.hero.tutor')}
name={instructorRole?.name ?? 'Instructor'}
intro={t('pbl.v2.hero.instructorTagline')}
tone="instructor"
className={scenarioCharacter ? undefined : 'col-span-2 sm:col-span-1'}
/>
{scenarioCharacter && (
<PersonCard
icon={<Drama className="h-4 w-4" />}
role={t('pbl.v2.hero.scenarioCharacter')}
name={scenarioCharacter.name}
intro={t('pbl.v2.hero.scenarioCharacterTagline')}
tone="character"
/>
)}
</div>
<div className="mt-7">
{started ? (
<div className="flex items-stretch gap-2">
<button onClick={handleContinue} className={LAUNCH_BUTTON_CLASS}>
<span className="absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent transition-transform duration-700 group-hover:translate-x-[420%]" />
{t('pbl.v2.hero.continueProject')}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
disabled={instructorStreaming}
className="flex w-12 shrink-0 items-center justify-center rounded-2xl border border-border/70 bg-background/50 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background/50 disabled:hover:text-muted-foreground"
aria-label={t('pbl.v2.hero.resetProgress')}
title={t('pbl.v2.hero.resetProgress')}
>
<RotateCcw className="h-4 w-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('pbl.v2.hero.resetConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('pbl.v2.hero.resetConfirmDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('pbl.v2.hero.resetConfirmCancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleReset}>
{t('pbl.v2.hero.resetConfirmConfirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : (
<button
onClick={() => void handleStart()}
disabled={launching}
className={LAUNCH_BUTTON_CLASS}
>
<span className="absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/25 to-transparent transition-transform duration-700 group-hover:translate-x-[420%]" />
{t('pbl.v2.hero.startProject')}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</button>
)}
<p className="text-xs text-muted-foreground text-center mt-3">
{t('pbl.v2.hero.startHint', { name: instructorRole?.name ?? 'Instructor' })}
</p>
</div>
</div>
</div>
</div>
);
}
const META_CARD_BASE =
'flex min-w-0 flex-col gap-1.5 rounded-2xl px-3.5 py-2.5 ring-1 ring-inset transition-colors';
/** Numeric count tile (stage / task) — icon + label on top, the big
* number anchored to the bottom so all cards in the row share a
* baseline. */
function StatCard({
icon,
value,
label,
}: {
readonly icon: React.ReactNode;
readonly value: string | number;
readonly label: string;
}) {
return (
<div
className={`${META_CARD_BASE} bg-foreground/[0.03] ring-foreground/[0.07] hover:bg-primary/[0.05] hover:ring-primary/25`}
>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary ring-1 ring-inset ring-primary/15">
{icon}
</span>
<span className="truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<div className="text-[1.6rem] font-bold leading-none tracking-tight text-foreground">
{value}
</div>
</div>
);
}
/** A "who you'll work with" card, sharing the StatCard footprint so the
* whole row aligns. `instructor` tone is the always-present mentor;
* `character` tone (amber, theatre-mask icon) is the SCENARIO-ONLY
* role-play character, deliberately styled apart so the learner can tell
* them from the mentor at a glance. */
function PersonCard({
icon,
role,
name,
intro,
tone,
className,
}: {
readonly icon: React.ReactNode;
readonly role: string;
readonly name: string;
readonly intro: string;
readonly tone: 'instructor' | 'character';
readonly className?: string;
}) {
const isCharacter = tone === 'character';
return (
<div
className={`${META_CARD_BASE} ${
isCharacter
? 'bg-amber-500/[0.06] ring-amber-500/20 hover:bg-amber-500/[0.1] hover:ring-amber-500/35'
: 'bg-foreground/[0.03] ring-foreground/[0.07] hover:bg-primary/[0.05] hover:ring-primary/25'
} ${className ?? ''}`}
>
<div className="flex items-center gap-2">
<span
className={
isCharacter
? 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-amber-500/15 text-amber-600 ring-1 ring-inset ring-amber-500/25 dark:text-amber-300'
: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary ring-1 ring-inset ring-primary/15'
}
>
{icon}
</span>
<span className="truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{role}
</span>
</div>
<div>
<div className="truncate text-[15px] font-bold leading-tight tracking-tight text-foreground">
{name}
</div>
<div className="mt-0.5 line-clamp-1 text-[11px] leading-snug text-muted-foreground">
{intro}
</div>
</div>
</div>
);
}
/** SCENARIO ONLY. Replaces the "stage / task" count tiles with a meaningful
* shape of the run: the fixed three acts (prep → roleplay → wrapup). When the
* middle has more than one roleplay act we show the count (e.g. "模拟 ×2");
* beats stay hidden. Shares the meta-card footprint so the row aligns. */
function ScenarioFlowCard({
label,
prep,
roleplay,
wrapup,
roleplayCount,
}: {
readonly label: string;
readonly prep: string;
readonly roleplay: string;
readonly wrapup: string;
readonly roleplayCount: number;
}) {
const mid = roleplayCount > 1 ? `${roleplay} ×${roleplayCount}` : roleplay;
return (
<div
className={`${META_CARD_BASE} bg-foreground/[0.03] ring-foreground/[0.07] hover:bg-primary/[0.05] hover:ring-primary/25`}
>
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary ring-1 ring-inset ring-primary/15">
<Layers className="h-4 w-4" />
</span>
<span className="truncate text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<div className="flex items-center gap-1.5 text-[13px] font-semibold leading-tight tracking-tight text-foreground">
<span>{prep}</span>
<span className="text-muted-foreground/60"></span>
<span className="text-primary">{mid}</span>
<span className="text-muted-foreground/60"></span>
<span>{wrapup}</span>
</div>
</div>
);
}