fix: 修复开发/测试包模拟进度被易班同步覆盖的问题

进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖;
现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度,
确保测试包的"到达某故居/全流程完成"调试入口生效。

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-23 10:19:36 +08:00
1 parent 6d40e19346
commit 49ecabbf3a
155 files changed
+1956 -724

No files matched your search

+141 -38
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'
import { Children, useEffect, useState } from 'react'
import { Children, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Image } from '@/components'
import { cn, getStaticImage } from '@/lib/utils'
@@ -95,6 +95,8 @@ export const StudyGuideModal = ({
audioChannelId,
}: StudyGuideModalProps) => {
const [mounted, setMounted] = useState(false)
const frameRef = useRef<HTMLDivElement>(null)
const [frameHeight, setFrameHeight] = useState(0)
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
const videoContext = useVideoContextSafe()
const { currentSrc, play, stop } = useAudioManager(
@@ -141,6 +143,18 @@ export const StudyGuideModal = ({
}
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop])
useEffect(() => {
if (!mounted || !visible || !frameRef.current) return
const updateFrameHeight = () => setFrameHeight(frameRef.current?.offsetHeight ?? 0)
updateFrameHeight()
const observer = new ResizeObserver(updateFrameHeight)
observer.observe(frameRef.current)
return () => observer.disconnect()
}, [mounted, visible, children])
if (!visible || !mounted) return null
const handleMaskClick = () => {
@@ -158,55 +172,144 @@ export const StudyGuideModal = ({
const contentChildren = renderIndentedContent(children, firstLineIndent)
const fillHeight = Math.max(467.43, frameHeight - 74)
const fillPath = [
'M76.22,0',
'L1658,0.3',
'C1658,0.3 1638.88,37.38 1669.5,69.87',
'C1700.12,102.36 1733.89,91.14 1733.89,91.14',
`L1733.54,${fillHeight - 91.14}`,
`C1733.54,${fillHeight - 91.14} 1699.76,${fillHeight - 102.35} 1669.15,${fillHeight - 69.87}`,
`C1638.54,${fillHeight - 37.38} 1657.65,${fillHeight - 0.3} 1657.65,${fillHeight - 0.3}`,
`L75.89,${fillHeight}`,
`C75.89,${fillHeight} 95,${fillHeight - 37.08} 64.39,${fillHeight - 69.57}`,
`C33.78,${fillHeight - 102.06} 0,${fillHeight - 90.84} 0,${fillHeight - 90.84}`,
'L0.33,90.84',
'C0.33,90.84 34.11,102.05 64.72,69.57',
'C95.34,37.08 76.22,0 76.22,0',
'Z',
].join(' ')
const content = (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ zIndex, pointerEvents: 'auto' }}
>
<div className="fixed inset-0" style={{ zIndex, pointerEvents: 'auto' }}>
{/* 背景遮罩 */}
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
{/* 引导内容 */}
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
{/* 引导框背景 */}
<div className={cn('absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2')}>
{/* 标题 */}
{_title &&
(_title === '温馨提示' ? (
<img
src={getStaticImage('components/guide/title')}
alt={_title}
className="pointer-events-none absolute left-1/2 -top-5 z-20 -translate-x-1/2"
/>
) : (
<div className="pointer-events-none absolute left-1/2 top-6 z-20 -translate-x-1/2 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
{_title}
</div>
))}
{/* 叶子 */}
<img
src={getStaticImage('components/guide/引导框')}
alt="学习引导背景"
className="absolute inset-0 w-full h-full object-contain"
src={getStaticImage('components/guide/竹叶')}
alt=""
className="pointer-events-none absolute left-10 top-16 z-20"
/>
{/* 文本内容 */}
<div
className={cn(
'relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto',
ailgn === 'left' && 'justify-start',
ailgn === 'right' && 'justify-end'
)}
>
{/* {title && (
<div className="mt-40 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
{title}
</div>
)} */}
{children && (
<div
className={cn(
'text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]',
'[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
)}
style={{
marginTop: verticalAlign === 'center' ? '85px' : '',
paddingLeft:
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
}}
{/* 星星 */}
<img
src={getStaticImage('components/guide/star')}
alt=""
className="pointer-events-none absolute left-1/2 top-20 z-20 -translate-x-1/2"
/>
{/* 内容 */}
<div ref={frameRef} className="relative z-10 w-[1818px] text-white pointer-events-auto">
{/* 中间不规则渐变填充 */}
<div
className="absolute z-0 pointer-events-none"
style={{
left: '38px',
top: '28px',
width: 'calc(100% - 80px)',
height: 'calc(100% - 54px)',
}}
>
<svg
className="absolute left-0 top-0 h-full w-full"
viewBox={`0 0 1733.87 ${fillHeight}`}
preserveAspectRatio="none"
aria-hidden="true"
>
{contentChildren}
</div>
)}
<defs>
<linearGradient
id="guide-fill"
x1="0"
y1="0"
x2="0"
y2={fillHeight}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#a98d80" />
<stop offset="100%" stopColor="#c5523d" />
</linearGradient>
</defs>
<path fill="url(#guide-fill)" d={fillPath} />
</svg>
</div>
{/* 边框层 */}
<div className="absolute inset-0 z-10 pointer-events-none">
<img
src={getStaticImage('components/guide/frame-top')}
alt=""
className="absolute left-0 top-0 w-full h-[135px]"
/>
<img
src={getStaticImage('components/guide/frame-middle')}
alt=""
className="absolute left-[32px] top-[134px]"
style={{
width: 'calc(100% - 68px)',
height: 'calc(100% - 268px)',
}}
/>
<img
src={getStaticImage('components/guide/frame-bottom')}
alt=""
className="absolute left-0 bottom-0 w-full h-[135px]"
/>
</div>
{/* 文本内容 */}
<div
className={cn(
'relative z-20 w-[1520px] flex items-center justify-center mx-auto text-white px-0 py-[130px]',
ailgn === 'left' && 'justify-start',
ailgn === 'right' && 'justify-end'
)}
>
{children && (
<div
className={cn(
'text-4xl leading-relaxed whitespace-pre-line overflow-visible custom-scrollbar',
'[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
)}
style={{
paddingLeft:
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
}}
>
{contentChildren}
</div>
)}
</div>
</div>
{/* 继续按钮 */}
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
<div className="text-right mr-16">
<Image
src={getStaticImage('components/guide/继续')}
alt="继续"
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
interface SyncErrorPromptOptions {
title?: string
message: string
onConfirm?: () => void
}
const SYNC_ERROR_PROMPT_EVENT = 'sync-error-prompt'
const shouldShowSyncPrompt = import.meta.env.MODE === 'production'
const dispatchSyncPrompt = (options: SyncErrorPromptOptions) => {
if (!shouldShowSyncPrompt) return
window.dispatchEvent(
new CustomEvent<SyncErrorPromptOptions>(SYNC_ERROR_PROMPT_EVENT, { detail: options })
)
}
export const showSyncErrorPrompt = (options: SyncErrorPromptOptions) => {
dispatchSyncPrompt(options)
}
export const showYibanUnauthenticatedPrompt = () => {
showSyncErrorPrompt({ message: '易班登录状态异常,请先登录易班平台后继续学习。' })
}
export const showYibanSyncErrorPrompt = () => {
showSyncErrorPrompt({ message: '学习进度获取失败,请稍后刷新重试。' })
}
export const SyncPromptHost = () => {
const [prompt, setPrompt] = useState<SyncErrorPromptOptions | null>(null)
useEffect(() => {
const handlePrompt = (event: Event) => {
const customEvent = event as CustomEvent<SyncErrorPromptOptions>
setPrompt(customEvent.detail)
}
window.addEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt)
return () => window.removeEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt)
}, [])
useEffect(() => {
if (!prompt) return
const timer = window.setTimeout(() => {
const onConfirm = prompt.onConfirm
setPrompt(null)
onConfirm?.()
}, 4500)
return () => window.clearTimeout(timer)
}, [prompt])
const handleClose = () => {
const onConfirm = prompt?.onConfirm
setPrompt(null)
onConfirm?.()
}
return (
<div className="pointer-events-none fixed inset-0 z-[10000]">
<AnimatePresence>
{prompt && (
<motion.div
key={prompt.message}
className="pointer-events-auto absolute bottom-12 right-12 w-[520px]"
initial={{ opacity: 0, x: 40, y: 12, filter: 'blur(2px)' }}
animate={{ opacity: 1, x: 0, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 32, y: 8, filter: 'blur(2px)' }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
<div className="relative overflow-hidden rounded-[18px] border-[3px] border-[#c44535] bg-[#f3e8c9]/95 px-8 py-6 shadow-[0_10px_30px_rgba(114,48,35,0.28)]">
<div className="pointer-events-none absolute left-0 top-0 h-full w-3 bg-[#c44535]" />
<button
type="button"
className="absolute right-4 top-3 text-3xl leading-none text-[#9b2f25] transition-opacity hover:opacity-70"
onClick={handleClose}
aria-label="关闭提示"
>
×
</button>
<div className="pr-8">
<div className="mb-2 text-3xl font-bold tracking-[4px] text-[#b8322b]">
{prompt.title ?? '温馨提示'}
</div>
<div className="text-2xl font-semibold leading-snug tracking-wide text-[#6d4a24]">
{prompt.message}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import {
SYNC_LOADING_EVENT,
SYNC_LOADING_HIDE_EVENT,
type SyncLoadingOptions,
} from './syncLoadingApi'
export const SyncLoadingHost = () => {
const [text, setText] = useState('正在同步学习进度...')
const [visible, setVisible] = useState(false)
useEffect(() => {
const handleShow = (event: Event) => {
const customEvent = event as CustomEvent<SyncLoadingOptions>
setText(customEvent.detail.text ?? '正在同步学习进度...')
setVisible(true)
}
const handleHide = () => {
setVisible(false)
}
window.addEventListener(SYNC_LOADING_EVENT, handleShow)
window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
return () => {
window.removeEventListener(SYNC_LOADING_EVENT, handleShow)
window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
}
}, [])
return (
<AnimatePresence>
{visible && (
<motion.div
key="sync-loading"
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<motion.div
className="flex flex-col items-center justify-center gap-8"
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.98 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
>
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="#f4e4bd"
strokeWidth="3"
/>
<path
className="opacity-90"
fill="#f4e4bd"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
{text}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
+9
View File
@@ -1,2 +1,11 @@
export { GuideSteps, GuideStepsItem } from './GuideSteps'
export { StudyGuideModal } from './StudyGuideModal'
export { SyncLoadingHost } from './SyncLoadingOverlay'
export { showSyncLoading, hideSyncLoading } from './syncLoadingApi'
export type { SyncLoadingOptions } from './syncLoadingApi'
export {
SyncPromptHost,
showSyncErrorPrompt,
showYibanUnauthenticatedPrompt,
showYibanSyncErrorPrompt,
} from './SyncErrorPrompt'
+18
View File
@@ -0,0 +1,18 @@
export interface SyncLoadingOptions {
text?: string
}
export const SYNC_LOADING_EVENT = 'sync-loading'
export const SYNC_LOADING_HIDE_EVENT = 'sync-loading-hide'
const dispatchSyncLoading = (detail: SyncLoadingOptions) => {
window.dispatchEvent(new CustomEvent<SyncLoadingOptions>(SYNC_LOADING_EVENT, { detail }))
}
export const showSyncLoading = (options?: SyncLoadingOptions) => {
dispatchSyncLoading(options ?? {})
}
export const hideSyncLoading = () => {
window.dispatchEvent(new Event(SYNC_LOADING_HIDE_EVENT))
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { cn, getBackgroundImage } from '@/lib/utils'
@@ -16,7 +16,7 @@ export interface ShowHintOptions {
persistent?: boolean
/** 相对中心点微调位置 */
offset?: HintPositionOffset
/** 自定义层级(默认 60) */
/** 自定义层级(默认 9999) */
zIndex?: number
}
@@ -30,7 +30,7 @@ const DEFAULT_OPTIONS: HintState['options'] = {
durationMs: 2000,
persistent: false,
offset: { x: 0, y: 0 },
zIndex: 60,
zIndex: 9999,
}
let current: HintState = {
+12 -1
View File
@@ -14,7 +14,18 @@ export type { ImageProps } from './image'
export { Title } from './title'
// Guide components
export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide'
export {
GuideSteps,
GuideStepsItem,
StudyGuideModal,
SyncLoadingHost,
SyncPromptHost,
showSyncLoading,
hideSyncLoading,
showSyncErrorPrompt,
showYibanUnauthenticatedPrompt,
showYibanSyncErrorPrompt,
} from './guide'
// Exhibit overlay
export { ExhibitOverlay } from './exhibit'
+17 -3
View File
@@ -21,6 +21,10 @@ export interface AlertProps {
const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertProps) => {
if (!visible) return null
const titleMatch = title.match(/^(第[一二三四五六七八九十]+幕)[::]["“]?(.+?)["”]?(?:--|——)(.+)$/)
const titlePrefix = titleMatch ? `${titleMatch[1]} ${titleMatch[2]}` : undefined
const titleContent = titleMatch?.[3] ?? title
return (
<div className="absolute inset-0 z-50 flex items-center justify-center">
{/* 蒙版背景 */}
@@ -39,12 +43,22 @@ const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertP
{/* 标题 */}
<div
className={cn([
'absolute -top-10',
'text-white text-7xl font-bold',
'absolute -top-40 flex flex-col items-center gap-3',
'text-white font-bold',
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]',
])}
>
{title}
{titlePrefix && (
<span
className={cn(
'text-[4.8rem] leading-none mb-8'
// '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)}
>
{titlePrefix}
</span>
)}
<span className="text-6xl leading-none">{titleContent}</span>
</div>
{/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest">{subtitle}</div>
+17 -32
View File
@@ -257,8 +257,8 @@ const MatchQuizPanel = ({
leftItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
'relative px-6 py-4 rounded-xl border-2 transition-all duration-200',
'text-[28px] leading-relaxed cursor-pointer',
'touch-manipulation',
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
isError
@@ -283,9 +283,9 @@ const MatchQuizPanel = ({
ref={(el) => {
leftDotRefs.current[item.id] = el
}}
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
>
<span className="text-white text-[10px] md:text-xs">✕</span>
<span className="text-white text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
@@ -293,9 +293,9 @@ const MatchQuizPanel = ({
ref={(el) => {
leftDotRefs.current[item.id] = el
}}
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center"
>
<span className="text-white text-xs md:text-sm">✓</span>
<span className="text-white text-sm">✓</span>
</div>
)}
</div>
@@ -313,8 +313,8 @@ const MatchQuizPanel = ({
rightItemRefs.current[item.id] = el
}}
className={cn(
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
'text-[20px] md:text-[28px] leading-relaxed',
'relative px-6 py-4 rounded-xl border-2 transition-all duration-200',
'text-[28px] leading-relaxed',
'touch-manipulation',
disabled || isMatched
? 'opacity-60 cursor-not-allowed'
@@ -345,9 +345,9 @@ const MatchQuizPanel = ({
ref={(el) => {
rightDotRefs.current[item.id] = el
}}
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
>
<span className="text-white text-[10px] md:text-xs">✕</span>
<span className="text-white text-xs">✕</span>
</div>
)}
{!isError && isMatched && (
@@ -355,9 +355,9 @@ const MatchQuizPanel = ({
ref={(el) => {
rightDotRefs.current[item.id] = el
}}
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center"
>
<span className="text-white text-xs md:text-sm">✓</span>
<span className="text-white text-sm">✓</span>
</div>
)}
</div>
@@ -372,25 +372,14 @@ const MatchQuizPanel = ({
style={{ zIndex }}
ref={containerRef}
>
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
<div className="text-[24px] md:text-[34px] leading-relaxed text-white">{title}</div>
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-8">
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-8">
<div className="text-[34px] leading-relaxed text-white">{title}</div>
{error && (
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
{error}
</div>
)}
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
</div>
{/* 移动端 */}
<div className="w-full max-w-[1600px] md:hidden flex flex-col gap-4 items-start justify-center">
<div className="w-full flex flex-col gap-3">{leftItems.map(renderLeftItem)}</div>
<div className="w-full flex flex-col gap-3">{rightItems.map(renderRightItem)}</div>
</div>
{/* 桌面端 */}
<div ref={quizAreaRef} className="hidden md:block w-full max-w-[1600px] relative">
<div ref={quizAreaRef} className="w-full max-w-[1600px] relative">
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */}
<svg
className="absolute inset-0 pointer-events-none overflow-visible z-10"
@@ -454,10 +443,6 @@ const MatchQuizPanel = ({
</div>
)}
</div>
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
请先点击左侧日期,再点击右侧对应的事件进行连线
</div>
</div>
</div>
)
+21 -12
View File
@@ -93,6 +93,7 @@ const LetterTemplate = ({
const [signatureName, setSignatureName] = useState('')
const [signatureError, setSignatureError] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [letterSent, setLetterSent] = useState(false)
const [showSendVideo, setShowSendVideo] = useState(false)
const [showReplyVideo, setShowReplyVideo] = useState(false)
const [showScene, setShowScene] = useState(false)
@@ -167,6 +168,7 @@ const LetterTemplate = ({
useEffect(() => {
if (!visible) {
setSubmitted(false)
setLetterSent(false)
setBlankValues([])
setBlankErrors([])
setSignatureName('')
@@ -417,7 +419,7 @@ const LetterTemplate = ({
<audio ref={audioRef} src={getPublicPath(audioSrc)} loop className="hidden" />
)}
{!showSendVideo && (
{!showSendVideo && !letterSent && (
<motion.div
className="fixed inset-0 bg-black/50 z-100"
initial={{ opacity: 0 }}
@@ -428,7 +430,7 @@ const LetterTemplate = ({
/>
)}
{!showSendVideo && (
{!showSendVideo && !letterSent && (
<motion.div
className="fixed inset-0 z-101 flex items-center justify-center pointer-events-none"
initial={{ opacity: 0, scale: 0.5, y: 100 }}
@@ -560,6 +562,7 @@ const LetterTemplate = ({
onClick={() => {
const nextValues = [...blankValues]
onSubmit?.(nextValues)
setLetterSent(true)
setShowSendVideo(true)
}}
button
@@ -596,13 +599,19 @@ const LetterTemplate = ({
)}
</AnimatePresence>
{showScene && !showReplyImage && replyImageSrc && (
{(showScene || showSendVideo) && replyImageSrc && (
<motion.div
className="fixed inset-0 z-102"
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
initial={false}
animate={
showScene && !showSendVideo && !showReplyImage
? { opacity: 1, y: 0 }
: { opacity: 0, y: 50 }
}
transition={{ duration: 0.5 }}
style={{
pointerEvents: showScene && !showSendVideo && !showReplyImage ? 'auto' : 'none',
}}
>
<img
src={getStaticImage(sceneImageSrc)}
@@ -636,20 +645,20 @@ const LetterTemplate = ({
</motion.div>
)}
{showReplyImage && replyImageSrc && (
{(showReplyImage || showScene) && replyImageSrc && (
<motion.div
className="fixed inset-0 z-103 flex items-center justify-center bg-black/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={false}
animate={showReplyImage ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3 }}
style={{ pointerEvents: showReplyImage ? 'auto' : 'none' }}
>
<motion.img
src={getStaticImage(replyImageSrc)}
alt="回信"
className="w-full h-full object-contain"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
initial={false}
animate={showReplyImage ? { scale: 1, opacity: 1 } : { scale: 0.8, opacity: 0 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
/>
@@ -82,7 +82,7 @@ const LetterToHuangyanpei = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="写给黄炎培先生"
title="写给黄炎培先生的信"
letterContent={letterContent}
fields={fields}
contentClassName="!text-[25px]"
+2 -2
View File
@@ -38,7 +38,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '首在立人',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '首在立人',
},
@@ -46,7 +46,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '批判求真',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '批判求真',
},
+5 -5
View File
@@ -45,7 +45,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '实事求是',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -53,7 +53,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '群众路线',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '群众路线',
},
@@ -61,7 +61,7 @@ const fields: Record<number, LetterFieldConfig> = {
...commonSelectField,
correctValue: '独立自主',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '独立自主',
},
@@ -76,7 +76,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '实事求是',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '实事求是',
},
@@ -91,7 +91,7 @@ const fields: Record<number, LetterFieldConfig> = {
],
correctValue: '群众路线',
hints: {
error: '请你结合上下文和所学在想想吧!',
error: '请你结合上下文和所学再想想吧!',
},
readOnlyFallback: '群众路线',
},
+2 -6
View File
@@ -367,12 +367,8 @@ const PuzzleSystem = ({
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)}
>
{/* 空格换行 */}
{selectedPieceData.title.split(' ').map((word: string, index: number) => (
<div key={index}>
{word}
{index < selectedPieceData.title.split(' ').length - 1 && ' '}
</div>
{selectedPieceData.title.split('\n').map((line: string, index: number) => (
<div key={index}>{line}</div>
))}
</div>
)}