| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407 |
- 'use client';
- import { useEffect, useState, type MouseEvent, type ReactNode } from 'react';
- import { ArrowLeft, ArrowRight, RotateCcw } from 'lucide-react';
- import type { Lesson, ReviewItem, ReviewLesson } from './lesson-types';
- import { getModule } from './module-catalog';
- import { courseHref } from './course-navigation';
- import {
- emptyReviewState,
- restoreReviewState,
- reviewProgress,
- reviewSignature,
- type ReviewState,
- } from './review-state';
- import './review.css';
- type Navigate = (module: string, lesson?: string) => void;
- function ReviewLink({
- module,
- lesson,
- navigate,
- children,
- className,
- }: {
- module: string;
- lesson?: string;
- navigate: Navigate;
- children: ReactNode;
- className?: string;
- }) {
- return (
- <a
- href={courseHref(module, lesson)}
- className={className}
- onClick={(event: MouseEvent<HTMLAnchorElement>) => {
- if (
- event.button !== 0 ||
- event.metaKey ||
- event.ctrlKey ||
- event.shiftKey ||
- event.altKey
- )
- return;
- event.preventDefault();
- navigate(module, lesson);
- }}
- >
- {children}
- </a>
- );
- }
- export function ReviewPage({
- lesson,
- lessons,
- navigate,
- }: {
- lesson: ReviewLesson;
- lessons: Lesson[];
- navigate: Navigate;
- }) {
- const [state, setState] = useState<ReviewState>(emptyReviewState);
- const [loaded, setLoaded] = useState(false);
- const [storageUnavailable, setStorageUnavailable] = useState(false);
- const [attempt, setAttempt] = useState(0);
- const storageKey = `shuhe:module-review:v1:${lesson.sourceModule}`;
- useEffect(() => {
- try {
- // oxlint-disable-next-line react/react-compiler -- Restore this browser's saved answers after hydration; server rendering cannot read localStorage.
- setState(restoreReviewState(localStorage.getItem(storageKey), lesson));
- } catch {
- setStorageUnavailable(true);
- }
- setLoaded(true);
- }, [lesson, storageKey]);
- function update(next: ReviewState) {
- setState(next);
- try {
- localStorage.setItem(
- storageKey,
- JSON.stringify({
- sourceModule: lesson.sourceModule,
- signature: reviewSignature(lesson),
- state: next,
- }),
- );
- } catch {
- setStorageUnavailable(true);
- }
- }
- const progress = reviewProgress(lesson, state);
- const source = getModule(lesson.sourceModule);
- const index = lessons.findIndex((item) => item.id === lesson.id);
- function question(item: ReviewItem, order: number, challenge = false) {
- const prefix = challenge ? '情境' : '问题';
- return (
- <article
- className="review-question"
- id={`review-${item.id}`}
- key={`${item.id}-${attempt}`}
- >
- <div className="review-question-number">
- {prefix} {String(order + 1).padStart(2, '0')}
- </div>
- <h3>{item.title}</h3>
- <p className="review-prompt">{item.prompt}</p>
- <details className="review-notes">
- <summary>记下我的回答(可选)</summary>
- <label htmlFor={`review-note-${item.id}`}>
- 先写判断、原因和验证方式,再看参考要点。
- </label>
- <textarea
- id={`review-note-${item.id}`}
- rows={4}
- maxLength={4000}
- disabled={!loaded}
- value={state.notes[item.id] || ''}
- onChange={(event) =>
- update({
- ...state,
- notes: { ...state.notes, [item.id]: event.target.value },
- })
- }
- />
- </details>
- <details className="review-answer">
- <summary>展开参考要点与易错点</summary>
- <div>
- <p>
- 对照推理是否完整。表述可以不同;能够解释原因、边界与验证方法,才算独立答出。
- </p>
- <ul>
- {item.checkpoints.map((point) => (
- <li key={point}>{point}</li>
- ))}
- </ul>
- <p className="review-misconception">
- <strong>容易误判:</strong>
- {item.misconception}
- </p>
- </div>
- </details>
- <fieldset className="review-rating" disabled={!loaded}>
- <legend>
- {challenge
- ? '合上要点后,我能独立推演这个变化吗?'
- : '合上要点后,我能用自己的话解释吗?'}
- </legend>
- <label>
- <input
- type="radio"
- name={`review-rating-${item.id}`}
- value="revisit"
- checked={state.answers[item.id] === 'revisit'}
- onChange={() =>
- update({
- ...state,
- answers: { ...state.answers, [item.id]: 'revisit' },
- })
- }
- />
- 还需要回学
- </label>
- <label>
- <input
- type="radio"
- name={`review-rating-${item.id}`}
- value="ready"
- checked={state.answers[item.id] === 'ready'}
- onChange={() =>
- update({
- ...state,
- answers: { ...state.answers, [item.id]: 'ready' },
- })
- }
- />
- {challenge ? '能独立推演并说明取舍' : '能独立解释并举例'}
- </label>
- </fieldset>
- <div className="review-revisit">
- <span>需要时回学:</span>
- {item.revisit.map((link) => (
- <ReviewLink
- key={link.lessonId}
- module={lesson.sourceModule}
- lesson={link.lessonId}
- navigate={navigate}
- >
- {link.lessonId} · {link.label}
- <ArrowRight size={13} />
- </ReviewLink>
- ))}
- </div>
- </article>
- );
- }
- return (
- <article className="lesson-article review-page">
- <ReviewLink module="review" navigate={navigate} className="back-link">
- <ArrowLeft size={14} />
- 复盘与自测 · 全部单元
- </ReviewLink>
- <div className="eyebrow">
- REVIEW {lesson.id} / {lessons.length}
- <span />
- {lesson.group}
- </div>
- <h1 className="lesson-title" tabIndex={-1}>
- {lesson.title}
- </h1>
- <p className="lead">{lesson.summary}</p>
- <div className="meta-row">
- <span>{source?.name}</span>
- <span>{lesson.questions.length} 个理解问题</span>
- <span>{lesson.challenges.length} 个情境推演</span>
- </div>
- <section id="review-start" className="lesson-start">
- <div className="section-heading">
- <h2>先独立回答,再对照</h2>
- </div>
- <p className="lesson-scenario">{lesson.intro.scenario}</p>
- <ul>
- {lesson.intro.startingPoint.map((item) => (
- <li key={item}>{item}</li>
- ))}
- </ul>
- <p className="lesson-outcome">
- <strong>本轮目标</strong>
- {lesson.intro.outcome}
- </p>
- <ol className="review-method">
- <li>先合上原课件,用自己的话回答;需要时记下思路。</li>
- <li>
- 展开要点找遗漏,再合上要点重述;依赖提示才能答出的题标为“还需要回学”。
- </li>
- <li>
- 完成情境推演,并核对四项掌握标准的实际证据。阅读过答案本身不作为掌握依据。
- </li>
- </ol>
- {state.contentChanged && (
- <p className="learning-section-note">
- 问题或掌握标准已有更新,旧笔记已保留供参考;旧自评已清除,请按本轮内容重新回答和核对证据。
- </p>
- )}
- <p className="learning-section-note">
- {storageUnavailable
- ? '当前浏览器无法保存记录,回答暂留在本页;离开前请自行保存。'
- : '回答与自评自动保存在当前浏览器,可下次继续。更换浏览器或清除浏览数据后需要重新记录。'}
- </p>
- </section>
- <section id="review-questions">
- <div className="section-heading">
- <h2>核心理解问题</h2>
- <span>说清发生了什么,以及为什么</span>
- </div>
- {lesson.questions.map((item, i) => question(item, i))}
- </section>
- <section id="review-challenges">
- <div className="section-heading">
- <h2>换个情况,再想一步</h2>
- <span>条件变化后,判断还成立吗</span>
- </div>
- {lesson.challenges.map((item, i) => question(item, i, true))}
- </section>
- <section id="review-mastery">
- <div className="section-heading">
- <h2>达到什么程度,算掌握这个模块</h2>
- </div>
- <p>
- 每项都需要能拿出下面的证据。操作类证据在自己的练习环境验证;设计类证据用图、推演或文字说明。
- </p>
- <fieldset className="review-evidence" disabled={!loaded}>
- <legend className="sr-only">掌握标准与证据</legend>
- {lesson.mastery.map((item) => (
- <label key={item.id} aria-label={item.text}>
- <input
- type="checkbox"
- checked={!!state.evidence[item.id]}
- onChange={(event) =>
- update({
- ...state,
- evidence: {
- ...state.evidence,
- [item.id]: event.target.checked,
- },
- })
- }
- />
- <span>
- <strong>{item.text}</strong>
- <span>{item.evidence}</span>
- </span>
- </label>
- ))}
- </fieldset>
- </section>
- <section id="review-result">
- <div className="section-heading">
- <h2>本轮自测结果与回学计划</h2>
- </div>
- <div
- className={`review-result${progress.mastered ? ' review-result-ready' : ''}`}
- aria-live="polite"
- >
- <strong>
- {progress.mastered
- ? '按你的自评,已达到本模块的掌握标准'
- : '本轮还有待验证的部分'}
- </strong>
- <p>
- 独立回答 {progress.ready} / {progress.total} 项 · 掌握证据{' '}
- {progress.evidence} / {progress.evidenceTotal} 项
- </p>
- <p>
- {progress.mastered
- ? '请隔一天换一个商品或故障条件,再闭卷推演一次,确认能够复用。这里记录的是你的自评,不对文字答案自动判分。'
- : '理解问题和情境推演都能独立完成,且四项证据均已核对,才达到本轮标准。先补齐下面的缺口,再回来自测。'}
- </p>
- </div>
- {progress.revisit.length > 0 && (
- <ul className="review-gaps">
- {progress.revisit.map((item) => (
- <li key={item.id}>
- <a href={`#review-${item.id}`}>{item.title}</a>
- <span>
- {state.answers[item.id] === 'revisit'
- ? '已标记需回学'
- : '尚未自评'}
- </span>
- </li>
- ))}
- </ul>
- )}
- {lesson.mastery.filter((item) => !state.evidence[item.id]).length >
- 0 && (
- <div className="review-pending-evidence">
- <h3>还需要补齐的掌握证据</h3>
- <ul>
- {lesson.mastery
- .filter((item) => !state.evidence[item.id])
- .map((item) => (
- <li key={item.id}>
- <a href="#review-mastery">{item.text}</a>
- </li>
- ))}
- </ul>
- </div>
- )}
- <div className="review-result-actions">
- <ReviewLink
- module={lesson.sourceModule}
- navigate={navigate}
- className="secondary-link"
- >
- 回到{source?.name}
- <ArrowRight size={15} />
- </ReviewLink>
- <button
- type="button"
- className="review-reset"
- disabled={!loaded}
- onClick={() => {
- update(emptyReviewState());
- setAttempt((value) => value + 1);
- }}
- >
- <RotateCcw size={14} />
- 清除本单元回答与自评,重新开始
- </button>
- </div>
- </section>
- <div className="lesson-pagination">
- <ReviewLink
- module="review"
- lesson={index > 0 ? lessons[index - 1].id : undefined}
- navigate={navigate}
- >
- <span>
- <ArrowLeft size={13} />
- {index > 0 ? '上一单元' : '复盘概览'}
- </span>
- <strong>
- {index > 0 ? lessons[index - 1].title : '选择要自测的模块'}
- </strong>
- </ReviewLink>
- <ReviewLink
- module="review"
- lesson={
- index < lessons.length - 1 ? lessons[index + 1].id : undefined
- }
- navigate={navigate}
- className="next"
- >
- <span>
- {index < lessons.length - 1 ? '下一单元' : '回到复盘概览'}
- <ArrowRight size={13} />
- </span>
- <strong>
- {index < lessons.length - 1
- ? lessons[index + 1].title
- : '选择下一轮要巩固的模块'}
- </strong>
- </ReviewLink>
- </div>
- </article>
- );
- }
|