Files
shanghai-residences/src/pages/huangyanpei/internalization.tsx
T
zhongyidianandClaude Opus 4.7 49ecabbf3a fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

同时纳入近期未提交的页面、组件、资源与环境变量改动。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:19:36 +08:00

558 lines
19 KiB
TypeScript
Raw 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.
import { useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Image,
Video,
type VideoAnchor,
type VideoRef,
type DragFillResult,
SingleChoiceQuizPanel,
LetterToHuangyanpei,
EndingOverlay,
Narration,
} from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { SuccessAlert, DragQuizPanel } from '@/components/layout'
import { MultipleChoiceQuizPanel } from '@/components/layout/quiz'
import { GuideSteps, GuideStepsItem } from '@/components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
type QuizOption = {
value: string
label: string
isCorrect?: boolean
explanation?: string
}
type QuizAnchor = {
id: string
time: number
title: string
prompt?: string
promptSuffix?: string
type: 'single' | 'multiple' | 'drag'
options: QuizOption[]
blanks?: { id: string; label: string }[]
blankPrefixes?: string[]
blankSeparators?: string[]
explanation?: string
}
const starName = 'huangyanpei'
// 锚点数据:根据视频时长调整 time
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 96, // 1:36
title: '',
prompt:
'【单选】黄炎培先生提出“双手万能、手脑并用”的职业教育的学习方式、教学原则的主要原因是:',
type: 'single',
options: [
{ value: 'A', label: '解决旧教育脱离生产劳动、人才供需错配的问题', isCorrect: true },
{
value: 'B',
label: '鼓励学生只专注于动手劳动,放弃文化学习',
explanation:
'黄炎培的主张是“要使动的读书,读书的动手”,强调动手与读书相结合,并非让学生放弃文化学习。请你再想想吧!',
},
{
value: 'C',
label: '为了完全照搬西方职业教育模式',
explanation:
'这一原则是针对中国旧教育的弊端和当时民族工业的实际需求提出的本土化理念。请你再想想吧!',
},
{
value: 'D',
label: '迎合传统旧式教育的理念',
explanation: '这一原则是为了解决旧教育严重脱离生产劳动的弊端。请你再想想吧!',
},
],
explanation:
'黄炎培提出“双手万能、手脑并用”是为了让学生既具备文化知识,又掌握实践技能,解决人才供需错配的矛盾。',
},
{
id: 'quiz-2b',
time: 177, // 2:56
title: '',
prompt: '【拖拽】黄炎培先生认为,职业学校的办学宗旨是“',
promptSuffix: '”。',
type: 'drag',
options: [
{ value: '有业', label: '有业' },
{ value: '乐业', label: '乐业' },
{ value: '就业', label: '就业' },
],
blanks: [
{ id: 'blank-1', label: '___' },
{ id: 'blank-2', label: '___' },
],
blankPrefixes: ['使无业者', '使有业者'],
blankSeparators: [','],
explanation: '正确答案为“使无业者有业,使有业者乐业”。',
},
{
id: 'quiz-4',
time: 261, // 4:20
title: '',
prompt: '【多选】黄炎培先生认为职业教育的目的是___,简称“四个准备”。',
type: 'multiple',
options: [
{
value: 'A',
label: '谋个性之发展,为个人谋生之准备',
isCorrect: true,
},
{
value: 'B',
label: '为个人服务社会之准备',
isCorrect: true,
},
{
value: 'C',
label: '为国家及世界增进生产力之准备',
isCorrect: true,
},
],
explanation: '你的选择还不够全面哦~请你再想想吧!',
},
{
id: 'quiz-2',
time: 221, // 3:41
title: '',
prompt: '【多选】黄炎培先生主导设计的课程与现在中职学校强调的理实一体这一理念相通是:',
type: 'multiple',
options: [
{
value: 'A',
label: '上午学机械原理,下午就去车间打铁、造台钳',
isCorrect: true,
},
{
value: 'B',
label: '课堂上学记账、推销,课后就去街头卖国货',
isCorrect: true,
},
{
value: 'C',
label: '学习四书五经、八股文',
},
{
value: 'D',
label: '在川沙小学堂采取“半天读书、半天做手工”的学习模式',
isCorrect: true,
},
],
explanation: '黄炎培认为教育不能只谈书本、脱离实际',
},
{
id: 'quiz-3',
time: 369, // 6:09
title: '',
prompt:
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: 'drag',
options: [
{ value: '特斯拉', label: '特斯拉' },
{ value: '比亚迪', label: '比亚迪' },
{ value: '华为', label: '华为' },
{ value: '江南造船', label: '江南造船' },
{ value: '光明食品', label: '光明食品' },
{ value: 'C919飞机', label: 'C919飞机' },
],
blanks: [
{ id: 'blank-1', label: '国产企业1' },
{ id: 'blank-2', label: '国产企业2' },
{ id: 'blank-3', label: '国产企业3' },
{ id: 'blank-4', label: '国产企业4' },
{ id: 'blank-5', label: '国产企业5' },
],
},
]
const HuangyanpeiInternalization = () => {
const navigate = useNavigate()
const { moduleName } = useParams<{ moduleName: string }>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
const [narrationVisible, setNarrationVisible] = useState(false)
const [activeSuccess, setActiveSuccess] = useState<string | null>(null)
const [successContent, setSuccessContent] = useState<string>('')
const [resumeCallback, setResumeCallback] = useState<(() => void) | null>(null)
const [dragQuizValues, setDragQuizValues] = useState<Record<string, DragFillResult[]>>({})
const [dragQuizErrors, setDragQuizErrors] = useState<Record<string, string>>({})
const [visible, setVisible] = useState(false)
const [showVideo, setShowVideo] = useState(false)
const [postGuideVisible, setPostGuideVisible] = useState(false)
const [showLetter, setShowLetter] = useState(false)
const [showStudyGuide, setShowStudyGuide] = useState(false)
const videoRef = useRef<VideoRef>(null)
useEffect(() => {
setNarrationVisible(true)
}, [])
const handleVideoCanPlay = () => {
if (showVideo && videoRef.current) {
videoRef.current.play().catch((error) => {
console.warn('视频播放失败:', error)
})
}
}
const anchors: VideoAnchor[] = useMemo(
() =>
QUIZ_ANCHORS.map((item) => {
const correctAnswers = item.options.filter((opt) => opt.isCorrect).map((opt) => opt.value)
// 拖拽题处理
if (item.type === 'drag' && item.blanks) {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => {
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
// 第 2 题:必须按顺序填入“有业”“乐业”
if (item.id === 'quiz-2b') {
const orderedAnswers = ['有业', '乐业']
const isCorrect =
val.length === orderedAnswers.length &&
orderedAnswers.every((answer, index) => val[index]?.optionValue === answer)
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: item.explanation || '请你再想想吧!',
}))
}
return
}
// 特斯拉标签无法拖入答案框
const hasInvalidOption = val.some((v) => v.optionValue === '特斯拉')
if (hasInvalidOption) {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '特斯拉为外资企业哦~',
}))
return
}
if (val.length < item.blanks!.length) return
// 检查是否都是正确答案(排除特斯拉)
const validAnswers = ['比亚迪', '华为', '江南造船', '光明食品', 'C919飞机']
const filledAnswers = val.map((v) => v.optionValue)
const isCorrect = filledAnswers.every((answer) => validAnswers.includes(answer))
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
} else {
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '特斯拉是美国品牌。请你再想想吧!',
}))
}
}
return (
<>
<DragQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={``}
questionPrefix={item.prompt}
questionSuffix={item.promptSuffix || '。'}
blankSeparators={item.blankSeparators}
options={item.options.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
blanks={item.blanks || []}
blankPrefixes={item.blankPrefixes}
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
setDragQuizValues((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
setDragQuizErrors((prev) => {
const next = { ...prev }
delete next[item.id]
return next
})
if (item.id === 'quiz-3') {
triggerPuzzleAnimation(16)
}
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
)
},
}
}
// 单选题处理
if (item.type === 'single') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<SingleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers[0] || ''}
onWrongSelect={(value) => {
const selected = item.options.find((opt) => opt.value === value)
return selected?.explanation || item.explanation || '请再想想吧!'
}}
onSubmit={(_value, isCorrect) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
// 多选题处理
if (item.type === 'multiple') {
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: ({ resume }) => (
<>
<MultipleChoiceQuizPanel
visible={activeSuccess !== item.id}
zIndex={10000}
title={`${item.prompt}`}
options={item.options.map((opt) => ({
value: opt.value,
label: `${opt.value}. ${opt.label}`,
}))}
correctAnswer={correctAnswers}
errorMessage={item.explanation || '再思考一下,注意选择所有正确答案。'}
onSubmit={(_values: string[], isCorrect: boolean) => {
if (isCorrect) {
setActiveSuccess(item.id)
setSuccessContent('')
setResumeCallback(() => resume)
}
}}
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
content={successContent}
onContinue={() => {
setActiveSuccess(null)
setSuccessContent('')
const cb = resumeCallback
setResumeCallback(null)
cb?.()
}}
/>
</>
),
}
}
return {
id: item.id,
time: item.time,
once: true,
afterResume: 'remove',
render: () => null,
}
}),
[
activeSuccess,
resumeCallback,
successContent,
dragQuizValues,
dragQuizErrors,
triggerPuzzleAnimation,
]
)
if (!moduleName) return null
return (
<div
className="relative h-full w-full bg-black"
style={{
backgroundImage: getBackgroundImage('components/internalization/bg'),
}}
>
{/* 离开按钮 */}
<Image
className="absolute right-8 top-8 z-10"
src={getStaticImage('components/离开')}
button
onClick={() => navigate(`/${moduleName}/${starName}/home`)}
/>
<Narration
visible={narrationVisible}
audioChannelId="huangyanpei-internalization-narration"
onClose={() => {
setNarrationVisible(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_27.mp3')}
text={`从川沙内史第的少年求学路,到中华职业教育社的拓荒征程,黄炎培先生用一生践行“教育救国”的初心,以“手脑并用”的理念为中国职业教育埋下火种。百年后的今天,这颗火种已长成燎原之势,照亮无数中职学子的成才之路。站在新时代的门槛上,我们回望先生的足迹,不仅是缅怀一段历史,更是要让“职教兴国”的精神,成为当代青年成长的指引。`}
animationSpeed={200}
/>
{showVideo && (
<Video
ref={videoRef}
src={getPublicPath('video/huangyanpei/huangyanpei_neihua.mp4')}
className="h-full w-full"
anchors={anchors}
playsInline
controls
onCanPlay={handleVideoCanPlay}
onEnded={() => {
setShowVideo(false)
setPostGuideVisible(true)
}}
/>
)}
{/* 引导 */}
<GuideSteps
visible={visible}
audioChannelId="huangyanpei-internalization-guide-pre"
onVisibleChange={(visible) => {
setVisible(visible)
if (!visible) {
setShowVideo(true)
handleVideoCanPlay()
}
}}
showMask={false}
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28_1.mp3')}
>
小沪,刚刚结束的“小职成长记”太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28_2.mp3')}
>
但我还有一个疑问:先生当年倡导的“手脑并用、工学结合”,放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_29.mp3')}
>
这个问题问到点子上啦!黄炎培先生的职教思想,从来不是尘封的历史,而是活在当下的行动指南。今天我们特别邀请到中华职业教育社第十三届理事会常务理事、中华职业学校党总支副书记、校长黄玉璟,让她带我们解码先生精神的时代密码!
</GuideStepsItem>
</GuideSteps>
{/* 视频结束后的引导 */}
<GuideSteps
visible={postGuideVisible}
audioChannelId="huangyanpei-internalization-guide-post"
onVisibleChange={(visible) => {
setPostGuideVisible(visible)
if (!visible) {
setShowLetter(true)
}
}}
showMask={false}
zIndex={60}
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_30.mp3')}
>
听完黄校长的讲解,相信你对黄炎培先生的职教精神有了更深的理解。现在,就请你把这份感悟写下来,既总结参观的收获,也给自己未来许下一个承诺吧!
</GuideStepsItem>
</GuideSteps>
{/* 信件组件 */}
<LetterToHuangyanpei
visible={showLetter}
onClose={() => {
setShowLetter(false)
}}
onAnimationEnd={() => {
setShowStudyGuide(true)
}}
showVideo={true}
/>
<EndingOverlay
visible={showStudyGuide}
text="恭喜你圆满完成黄炎培故居的学习之旅,请带着研学感悟,前往聂耳旧居继续探访吧~"
animationSpeed={200}
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
/>
</div>
)
}
export default HuangyanpeiInternalization