feat: add puzzle residence configuration and reflection progress logic

- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability.
- Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
zhongyidian committed 2026-04-27 11:49:30 +08:00
1 parent e0fa47d21a
commit f5f36f75b5
153 files changed
+3846 -2352

No files matched your search

+107 -5
View File
@@ -5,6 +5,14 @@ import { filterInput } from '@/lib/utils/sensitiveWords'
import { Image, Select, Video, type VideoRef } from '@/components'
import { showHint } from '@/components/hint'
const formatCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
}
export type LetterFieldType = 'text' | 'input' | 'select'
export interface LetterFieldOption {
@@ -47,6 +55,7 @@ export interface LetterTemplateProps {
audioSrc?: string
showVideo?: boolean
videoSrc?: string
replyVideoSrc?: string
sceneImageSrc?: string
replyButtonImageSrc?: string
replyImageSrc?: string
@@ -74,19 +83,26 @@ const LetterTemplate = ({
audioSrc,
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyVideoSrc,
sceneImageSrc = 'components/letter/动画结束后场景',
replyButtonImageSrc = 'components/letter/点击敬读',
replyImageSrc,
}: LetterTemplateProps) => {
const [blankValues, setBlankValues] = useState<string[]>([])
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
const [signatureName, setSignatureName] = useState('')
const [signatureError, setSignatureError] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [showSendVideo, setShowSendVideo] = useState(false)
const [showReplyVideo, setShowReplyVideo] = useState(false)
const [showScene, setShowScene] = useState(false)
const [showReplyImage, setShowReplyImage] = useState(false)
const sendVideoRef = useRef<VideoRef>(null)
const replyVideoRef = useRef<VideoRef>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const currentDate = useMemo(() => formatCurrentDate(), [])
const resolvedFields = useMemo(
() => (typeof fields === 'function' ? fields(blankValues) : fields),
[fields, blankValues]
@@ -99,6 +115,13 @@ const LetterTemplate = ({
(_, index) => blankValues[index] ?? ''
)
const trimmedSignatureName = signatureName.trim()
if (!trimmedSignatureName) {
showHint('请填写落款姓名后再提交哦!')
setSignatureError(true)
return false
}
const nextErrors = validate
? validate(nextValues, letterContent)
: nextValues.map((value, index) => {
@@ -146,7 +169,10 @@ const LetterTemplate = ({
setSubmitted(false)
setBlankValues([])
setBlankErrors([])
setSignatureName('')
setSignatureError(false)
setShowSendVideo(false)
setShowReplyVideo(false)
setShowScene(false)
setShowReplyImage(false)
}
@@ -157,14 +183,14 @@ const LetterTemplate = ({
const audio = audioRef.current
if (!audio || !audioSrc) return
if (visible && !showSendVideo) {
if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) {
audio.currentTime = 0
audio.play().catch(() => {})
} else {
audio.pause()
audio.currentTime = 0
}
}, [visible, showSendVideo, audioSrc])
}, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc])
useEffect(() => {
const audio = audioRef.current
@@ -252,6 +278,48 @@ const LetterTemplate = ({
}
}
const handleReplyVideoCanPlay = () => {
if (showReplyVideo && replyVideoRef.current) {
replyVideoRef.current.play().catch(() => {})
}
}
const handleSignatureNameChange = (value: string) => {
const nextValue = filterInput(value)
setSignatureName(nextValue)
const hasError = !nextValue.trim()
if (signatureError !== hasError) {
setSignatureError(hasError)
}
}
const renderSignature = (readonly: boolean) => {
if (readonly) {
return (
<div className="absolute right-[240px] bottom-[110px] z-99 flex flex-col items-end text-[26px] leading-[1.8] text-[#4e402e]">
<span>{signatureName.trim() || '  '}</span>
<span>{currentDate}</span>
</div>
)
}
return (
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]">
<input
name="signature-name"
className={`h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[120px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: '26px' }}
autoComplete="off"
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
/>
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
</div>
)
}
const renderLine = (readonly: boolean) => {
let placeholderIndex = 0
let bodyStarted = false
@@ -354,7 +422,7 @@ const LetterTemplate = ({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={onClose}
// onClick={onClose}
/>
)}
@@ -416,6 +484,8 @@ const LetterTemplate = ({
</div>
</div>
{renderSignature(false)}
<div className="absolute bottom-[150px] -right-30">
<img
src={getStaticImage('components/letter/笔')}
@@ -424,7 +494,7 @@ const LetterTemplate = ({
/>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
<div className="absolute bottom-[80px] left-62 z-99">
<Image
src={getStaticImage('components/letter/可提交状态')}
alt="可提交状态"
@@ -468,7 +538,9 @@ const LetterTemplate = ({
</div>
</div>
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
{renderSignature(true)}
<div className="absolute bottom-[80px] left-50 z-99">
<Image
src={getStaticImage('components/letter/寄出')}
alt="寄出"
@@ -577,12 +649,42 @@ const LetterTemplate = ({
onClick={() => {
setShowReplyImage(false)
setShowScene(false)
if (replyVideoSrc) {
setShowReplyVideo(true)
return
}
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
<AnimatePresence>
{showReplyVideo && replyVideoSrc && (
<motion.div
key="reply-video"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.5 }}
className="fixed inset-0 z-104 h-full w-full bg-black"
>
<Video
ref={replyVideoRef}
src={getPublicPath(replyVideoSrc)}
className="h-full w-full"
playsInline
onCanPlay={handleReplyVideoCanPlay}
onEnded={() => {
setShowReplyVideo(false)
onClose?.()
onAnimationEnd?.()
}}
/>
</motion.div>
)}
</AnimatePresence>
</>
)}
</AnimatePresence>
+9 -2
View File
@@ -9,6 +9,7 @@ export interface LetterToChenyunProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const letterContent = `敬爱的陈云爷爷:
@@ -31,7 +32,11 @@ const fields: Record<number, LetterFieldConfig> = {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
readOnlyFallback: '勤学钻研',
correctValue: '实',
readOnlyFallback: '实',
hints: {
error: '“不唯上、不唯书、只唯实”是陈云提出的著名方法论原则。',
},
},
2: {
type: 'input',
@@ -62,6 +67,7 @@ const LetterToChenyun = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-陈云',
replyVideoSrc = 'video/letter/chenyun_gaizhang.mp4',
}: LetterToChenyunProps) => {
return (
<LetterTemplate
@@ -69,12 +75,13 @@ const LetterToChenyun = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给陈云的信"
title="写给陈云爷爷的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToHuangyanpeiProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -73,6 +74,7 @@ const LetterToHuangyanpei = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-黄炎培',
replyVideoSrc = 'video/letter/huangyanpei_gaizhang.mp4',
}: LetterToHuangyanpeiProps) => {
return (
<LetterTemplate
@@ -87,6 +89,7 @@ const LetterToHuangyanpei = ({
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+10 -7
View File
@@ -9,6 +9,7 @@ export interface LetterToLuxunProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -17,7 +18,7 @@ const commonSelectField: LetterFieldConfig = {
fitContent: false,
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '旨在立人', label: '旨在立人' },
{ value: '首在立人', label: '首在立人' },
{ value: '批判求真', label: '批判求真' },
],
}
@@ -28,18 +29,18 @@ const letterContent = `敬爱的鲁迅先生:
今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。
我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
我是____专业的学生,今后,我会以您“____千夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!`
const fields: Record<number, LetterFieldConfig> = {
0: {
...commonSelectField,
correctValue: '旨在立人',
correctValue: '首在立人',
hints: {
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '旨在立人',
readOnlyFallback: '首在立人',
},
1: {
...commonSelectField,
@@ -85,8 +86,8 @@ const fields: Record<number, LetterFieldConfig> = {
type: 'input',
maxLength: 16,
widthClassName: 'w-[120px]',
correctValue: '世上本没有路',
readOnlyFallback: '世上本没有路',
correctValue: '地上本没有路',
readOnlyFallback: '地上本没有路',
hints: {
error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”',
},
@@ -108,6 +109,7 @@ const LetterToLuxun = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-鲁迅',
replyVideoSrc = 'video/letter/luxun_gaizhang.mp4',
}: LetterToLuxunProps) => {
return (
<LetterTemplate
@@ -115,12 +117,13 @@ const LetterToLuxun = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给鲁迅的信"
title="写给鲁迅先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+11 -6
View File
@@ -9,6 +9,7 @@ export interface LetterToMaozedongProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -44,7 +45,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -52,7 +53,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '群众路线',
},
@@ -60,7 +61,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '独立自主',
hints: {
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '独立自主',
},
@@ -75,7 +76,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '实事求是',
hints: {
error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -90,7 +91,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '群众路线',
hints: {
error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。',
error: '请你结合上下文和所学在想想吧!',
},
readOnlyFallback: '群众路线',
},
@@ -128,20 +129,24 @@ const LetterToMaozedong = ({
audioSrc = 'audio/letter/1东方红.m4a',
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyVideoSrc = 'video/letter/mzd_gaizhang.mp4',
replyImageSrc = 'components/letter/点击信详情-毛泽东',
}: LetterToMaozedongProps) => {
// const stampedVideoSrc = videoSrc.replace('neihua_jixin.mp4', 'maozedong_gaizhang.mp4')
return (
<LetterTemplate
visible={visible}
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给毛泽东的信"
title="写给毛泽东主席的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
+4 -1
View File
@@ -9,6 +9,7 @@ export interface LetterToNieerProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -87,6 +88,7 @@ const LetterToNieer = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-聂耳',
replyVideoSrc = 'video/letter/nieer_gaizhang.mp4',
}: LetterToNieerProps) => {
return (
<LetterTemplate
@@ -94,13 +96,14 @@ const LetterToNieer = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给聂耳的信"
title="写给聂耳先生的信"
letterContent={letterContent}
contentClassName="!text-[25px]"
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToRongdeshengProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -79,6 +80,7 @@ const LetterToRongdesheng = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-荣德生',
replyVideoSrc = 'video/letter/rongdesheng_gaizhang.mp4',
}: LetterToRongdeshengProps) => {
return (
<LetterTemplate
@@ -86,12 +88,13 @@ const LetterToRongdesheng = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给荣德生的信"
title="写给荣德生先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToSunzhongshanProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const commonSelectField: LetterFieldConfig = {
@@ -92,6 +93,7 @@ const LetterToSunzhongshan = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-孙中山',
replyVideoSrc = 'video/letter/sunzhongshan_gaizhang.mp4',
}: LetterToSunzhongshanProps) => {
return (
<LetterTemplate
@@ -99,12 +101,13 @@ const LetterToSunzhongshan = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给孙中山的信"
title="写给孙中山先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)
@@ -9,6 +9,7 @@ export interface LetterToZhanglepingProps {
showVideo?: boolean
videoSrc?: string
replyImageSrc?: string
replyVideoSrc?: string
}
const letterContent = `敬爱的张乐平先生:
@@ -102,6 +103,7 @@ const LetterToZhangleping = ({
showVideo = true,
videoSrc = 'video/letter/neihua_jixin.mp4',
replyImageSrc = 'components/letter/点击信详情-张乐平',
replyVideoSrc = 'video/letter/zhangleping_gaizhang.mp4',
}: LetterToZhanglepingProps) => {
return (
<LetterTemplate
@@ -109,12 +111,13 @@ const LetterToZhangleping = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给张乐平的信"
title="写给张乐平先生的信"
letterContent={letterContent}
fields={fields}
audioSrc={audioSrc}
showVideo={showVideo}
videoSrc={videoSrc}
replyVideoSrc={replyVideoSrc}
replyImageSrc={replyImageSrc}
/>
)