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

+46 -10
View File
@@ -5,7 +5,15 @@ import { ScaleProvider, useScale } from './contexts/ScaleContext'
import { RotateHint } from './components/RotateHint'
import { HintHost } from './components'
import { useUserStore } from './store'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import {
SyncLoadingHost,
SyncPromptHost,
hideSyncLoading,
showSyncLoading,
showYibanSyncErrorPrompt,
showYibanUnauthenticatedPrompt,
} from '@/components'
import {
usePuzzleStore,
deserializePuzzles,
@@ -13,6 +21,13 @@ import {
PUZZLE_KEY,
} from '@/store/puzzleStore'
const applyStoredProgressionDebugMode = async () => {
if (import.meta.env.VITE_DEBUG_ENTRY !== 'true') return false
const debug = await import('@/debug/progressionDebug')
return debug.applyStoredProgressionDebugMode()
}
/**
* 应用内容组件
* 使用 useScale 获取缩放样式
@@ -36,20 +51,39 @@ function AppContent() {
useEffect(() => {
;(async () => {
showSyncLoading()
try {
const raw = await getUserDefine(PUZZLE_LESSON_ID, PUZZLE_KEY)
if (raw) {
const data = JSON.parse(raw) as { puzzles?: Record<string, number[]> }
if (data.puzzles) {
usePuzzleStore.setState((prev) => ({
...prev,
puzzles: deserializePuzzles(data.puzzles),
}))
}
const result = await getUserDefineResult(PUZZLE_LESSON_ID, PUZZLE_KEY)
if (result.status === 'success') {
const data = JSON.parse(result.content) as { puzzles?: Record<string, number[]> }
usePuzzleStore.setState((prev) => ({
...prev,
puzzles: deserializePuzzles(data.puzzles),
}))
return
}
usePuzzleStore.setState((prev) => ({
...prev,
puzzles: {},
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
} catch (e) {
usePuzzleStore.setState((prev) => ({
...prev,
puzzles: {},
}))
showYibanSyncErrorPrompt()
console.error('Failed to load puzzle store', e)
} finally {
await applyStoredProgressionDebugMode()
hideSyncLoading()
usePuzzleStore.getState().setLoaded(true)
}
})()
@@ -67,6 +101,8 @@ function AppContent() {
<Suspense fallback={<div>Loading...</div>}>
<RouterProvider router={router} />
<HintHost />
<SyncPromptHost />
<SyncLoadingHost />
</Suspense>
</div>
</div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 KiB

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 798 KiB

After

Width:  |  Height:  |  Size: 817 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

After

Width:  |  Height:  |  Size: 3.1 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1733.87 467.43"><defs><linearGradient id="guide-fill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#a98d80"/><stop offset="100%" stop-color="#c5523d"/></linearGradient></defs><path fill="url(#guide-fill)" d="M1733.54,376.29s-33.78-11.21-64.39,21.27-11.5,69.57-11.5,69.57l-1581.76.3S95,430.35,64.39,397.86,0,376.59,0,376.59L.33,90.84s33.78,11.21,64.39-21.27S76.22,0,76.22,0L1658,.3s-19.12,37.08,11.5,69.57,64.39,21.27,64.39,21.27Z"/></svg>

After

Width:  |  Height:  |  Size: 505 B

+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>
)}
-22
View File
@@ -3,15 +3,6 @@ import { PUZZLE_RESIDENCES } from './puzzleResidences'
export type PuzzleProgress = Record<string, Set<number>>
const BUILD_UNLOCK_ALL = import.meta.env.VITE_PROGRESS_UNLOCK_ALL === 'true'
const UNLOCK_ALL_STORAGE_KEY = 'debug:progression-unlock-all'
const isUnlockAllEnabled = () => {
if (!BUILD_UNLOCK_ALL) return false
if (typeof window === 'undefined') return true
return window.sessionStorage.getItem(UNLOCK_ALL_STORAGE_KEY) !== 'false'
}
const getResidenceIndex = (residence: string) =>
PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
@@ -33,13 +24,10 @@ export const isResidenceStarted = (residence: string, puzzles: PuzzleProgress) =
}
export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence))
}
export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const index = getResidenceIndex(residence)
if (index === 0) return true
if (index < 0) return false
@@ -62,8 +50,6 @@ export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress)
}
export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
if (residences.length === 0) return false
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
@@ -79,14 +65,6 @@ const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
}
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) {
return {
dialogueUnlocked: true,
reflectionUnlocked: true,
internalizationUnlocked: true,
}
}
const residenceUnlocked = isResidenceUnlocked(residence, puzzles)
return {
+29 -29
View File
@@ -26,7 +26,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片2
2: {
title: '陈云就读 青浦县立乙种商业学校',
title: '陈云就读\n青浦县立乙种商业学校',
detailImage: 'chenyun/details/2',
route: 'place2',
},
@@ -38,7 +38,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片4
4: {
title: '陈云在 上海商务印书馆当学徒',
title: '陈云在\n上海商务印书馆当学徒',
detailImage: 'chenyun/details/4',
route: 'place3',
},
@@ -50,19 +50,19 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片6
6: {
title: '陈云被选为 中央政治局委员、常委',
title: '陈云被选为\n中央政治局委员、常委',
detailImage: 'chenyun/details/6',
route: 'place4',
},
// 碎片7
7: {
title: '陈云任 西北财经办事处副主任',
title: '陈云任\n西北财经办事处副主任',
detailImage: 'chenyun/details/7',
route: 'place5',
},
// 碎片8
8: {
title: '陈云主持 陕甘宁边区财政工作',
title: '陈云主持\n陕甘宁边区财政工作',
detailImage: 'chenyun/details/8',
route: 'place5',
},
@@ -80,7 +80,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片11
11: {
title: '陈云起草的关于发展国民 经济“一五”计划报告的手稿',
title: '陈云起草的关于发展\n国民经济“一五”计划\n报告的手稿',
detailImage: 'chenyun/details/11',
route: 'place8',
},
@@ -110,7 +110,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片16
16: {
title: '陈云精神',
title: '陈云给青年的精神指引',
detailImage: 'chenyun/details/16',
route: 'internalization',
},
@@ -148,7 +148,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片6
6: {
title: '职业教育 和中华职业学校',
title: '职业教育\n和中华职业学校',
detailImage: 'huangyanpei/details/6',
route: 'place4',
},
@@ -172,7 +172,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片10
10: {
title: '黄炎培与荣氏兄弟 合作工人教育',
title: '黄炎培与荣氏兄弟\n合作工人教育',
detailImage: 'huangyanpei/details/10',
route: 'place4',
},
@@ -196,7 +196,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片14
14: {
title: '黄炎培对浦东中学 新生的教导',
title: '黄炎培对浦东中学\n新生的教导',
detailImage: 'huangyanpei/details/14',
route: 'reflection2',
},
@@ -225,7 +225,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place1',
},
3: {
title: '《申报》刊登孙中山就职 临时大总统消息',
title: '《申报》刊登孙中山就职\n临时大总统消息',
detailImage: 'sunzhongshan/details/3',
route: 'place1',
},
@@ -260,7 +260,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
10: {
title: '中国国民党第一次 全国代表大会宣言',
title: '中国国民党第一次\n全国代表大会宣言',
detailImage: 'sunzhongshan/details/10',
route: 'place3',
},
@@ -285,7 +285,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'reflection3',
},
15: {
title: '振兴中华对新时代 青年的意义',
title: '振兴中华对新时代\n青年的意义',
detailImage: 'sunzhongshan/details/15',
route: 'internalization',
},
@@ -297,7 +297,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
luxun: {
1: {
title: '鲁迅1927年南下 广州时的照片',
title: '鲁迅1927年南下\n广州时的照片',
detailImage: 'luxun/details/1',
route: 'place1',
},
@@ -414,7 +414,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
8: {
title: '毛泽东签发的两份 中共四大通知',
title: '毛泽东签发的两份\n中共四大通知',
detailImage: 'maozedong/details/8',
route: 'place4',
},
@@ -526,7 +526,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place6',
},
14: {
title: '唱国家一定要站起来',
title: '唱国歌一定要站起来',
detailImage: 'nieer/details/14',
route: 'reflection',
},
@@ -543,7 +543,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
zhangleping: {
1: {
title: '童年的回忆 (张乐平,1983)',
title: '童年的回忆\n(张乐平,1983)',
detailImage: 'zhangleping/details/1',
route: 'place1',
},
@@ -553,17 +553,17 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place1',
},
3: {
title: '穷凶极恶的结果 (张乐平,1937)',
title: '穷凶极恶的结果\n(张乐平,1937)',
detailImage: 'zhangleping/details/3',
route: 'place2',
},
4: {
title: '长沙大火前夕 张乐平在街头墙上挥笔作画',
title: '长沙大火前夕\n张乐平在街头墙上挥笔作画',
detailImage: 'zhangleping/details/4',
route: 'place2',
},
5: {
title: '侵略者的坟墓 (张乐平,1938)',
title: '侵略者的坟墓\n(张乐平,1938)',
detailImage: 'zhangleping/details/5',
route: 'place2',
},
@@ -645,7 +645,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2',
},
5: {
title: '上海申新第九纺织厂门楼(1933年旧照)',
title: '上海申新第九纺织厂门楼\n(1933年旧照)',
detailImage: 'rongdesheng/details/5',
route: 'place2',
},
@@ -655,22 +655,22 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2',
},
7: {
title: '国际劳工局赞美申新三纺织厂劳工自治区',
title: '国际劳工局赞美\n申新三纺织厂劳工自治区',
detailImage: 'rongdesheng/details/7',
route: 'place2',
},
8: {
title: '上海申新一厂和八厂于1937年被日本飞机炸毁',
title: '上海申新一厂和八厂于\n1937年被日本飞机炸毁',
detailImage: 'rongdesheng/details/8',
route: 'place3',
},
9: {
title: '《人报》报道 乐农氏再度捐助面粉一万包',
title: '《人报》报道\n乐农氏再度捐助面粉一万包',
detailImage: 'rongdesheng/details/9',
route: 'place3',
},
10: {
title: '心正思无邪 意诚言必中题字',
title: '心正思无邪\n意诚言必中 题字',
detailImage: 'rongdesheng/details/10',
route: 'place3',
},
@@ -680,19 +680,19 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
12: {
title: '荣德生拒绝荣氏工业结构外迁的报道',
title: '荣德生拒绝荣氏工业结构\n外迁的报道',
detailImage: 'rongdesheng/details/12',
route: 'place4',
},
13: {
title: '毛泽东主席、陈毅副总理在荣毅仁陪同下,参观公私合营上海申新第九纺织厂',
title: '毛泽东主席、陈毅副总理在\n荣毅仁陪同下,参观公私合\n营上海申新第九纺织厂',
detailImage: 'rongdesheng/details/13',
route: 'place4',
},
14: {
title: '荣德生与张 一九四九年九月,荣德生致函第一届全国政协謇的合影',
title: '民生坚守',
detailImage: 'rongdesheng/details/14',
route: 'place4',
route: 'reflection',
},
15: {
title: '坚韧不拔',
+221 -17
View File
@@ -1,42 +1,238 @@
const STORAGE_KEY = 'debug:progression-unlock-all'
const BADGE_ID = 'progression-debug-badge'
import { puzzleDataConfig } from '@/config/puzzleData'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
const isUnlockAllEnabled = () => {
return window.sessionStorage.getItem(STORAGE_KEY) !== 'false'
const MODE_KEY = 'debug:progression-mode'
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
const BADGE_ID = 'progression-debug-badge'
const PANEL_ID = 'progression-debug-panel'
type DebugMode =
| { type: 'real' }
| { type: 'arrived'; residence: string; label: string }
| { type: 'allCompleted' }
const serializePuzzles = (puzzles: Record<string, Set<number>>) => {
return Object.fromEntries(
Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]])
)
}
const getBadgeText = () => {
return isUnlockAllEnabled() ? '测试包:全解锁' : '测试包:真实进度'
const deserializePuzzles = (puzzles: Record<string, number[]> | undefined) => {
if (!puzzles) return {}
return Object.fromEntries(
Object.entries(puzzles).map(([residence, pieces]) => [residence, new Set(pieces)])
)
}
const getAllPieceIds = (residence: string) => {
return Object.keys(puzzleDataConfig[residence] ?? {}).map(Number)
}
const buildArrivedPuzzles = (targetResidence: string) => {
const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === targetResidence)
const puzzles: Record<string, Set<number>> = {}
if (targetIndex <= 0) return puzzles
for (let index = 0; index < targetIndex; index++) {
const residence = PUZZLE_RESIDENCES[index]?.residence
if (!residence) continue
puzzles[residence] = new Set(getAllPieceIds(residence))
}
return puzzles
}
const buildAllCompletedPuzzles = () => {
const puzzles: Record<string, Set<number>> = {}
for (const { residence } of PUZZLE_RESIDENCES) {
puzzles[residence] = new Set(getAllPieceIds(residence))
}
return puzzles
}
const getStoredMode = (): DebugMode => {
const raw = window.sessionStorage.getItem(MODE_KEY)
if (!raw) return { type: 'real' }
try {
const mode = JSON.parse(raw) as DebugMode
if (mode.type === 'arrived' || mode.type === 'allCompleted') return mode
} catch {
return { type: 'real' }
}
return { type: 'real' }
}
const setStoredMode = (mode: DebugMode) => {
if (mode.type === 'real') {
window.sessionStorage.removeItem(MODE_KEY)
return
}
window.sessionStorage.setItem(MODE_KEY, JSON.stringify(mode))
}
const getModeLabel = (mode = getStoredMode()) => {
if (mode.type === 'arrived') return `到达:${mode.label}`
if (mode.type === 'allCompleted') return '全流程完成'
return '真实进度'
}
const backupRealPuzzles = () => {
if (window.sessionStorage.getItem(BACKUP_KEY)) return
const puzzles = usePuzzleStore.getState().puzzles
window.sessionStorage.setItem(BACKUP_KEY, JSON.stringify(serializePuzzles(puzzles)))
}
const restoreRealPuzzles = () => {
const raw = window.sessionStorage.getItem(BACKUP_KEY)
if (!raw) return
try {
const data = JSON.parse(raw) as Record<string, number[]>
usePuzzleStore.setState({ puzzles: deserializePuzzles(data) })
} catch {
window.sessionStorage.removeItem(BACKUP_KEY)
}
}
const applyDebugModePuzzles = (mode: Exclude<DebugMode, { type: 'real' }>) => {
const puzzles =
mode.type === 'allCompleted' ? buildAllCompletedPuzzles() : buildArrivedPuzzles(mode.residence)
usePuzzleStore.setState((prev) => ({ ...prev, puzzles }))
}
export const applyStoredProgressionDebugMode = () => {
const mode = getStoredMode()
if (mode.type === 'real') return false
applyDebugModePuzzles(mode)
return true
}
const applyMode = (mode: DebugMode) => {
if (mode.type === 'real') {
restoreRealPuzzles()
setStoredMode(mode)
window.sessionStorage.removeItem(BACKUP_KEY)
window.location.reload()
return
}
backupRealPuzzles()
applyDebugModePuzzles(mode)
setStoredMode(mode)
window.location.reload()
}
const createButton = (label: string, onClick: () => void) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = label
button.style.width = '100%'
button.style.border = '1px solid rgba(255,255,255,0.2)'
button.style.borderRadius = '6px'
button.style.background = 'rgba(255,255,255,0.1)'
button.style.color = '#fff'
button.style.padding = '6px 8px'
button.style.textAlign = 'left'
button.style.cursor = 'pointer'
button.addEventListener('click', onClick)
return button
}
const removePanel = () => {
document.getElementById(PANEL_ID)?.remove()
}
const togglePanel = () => {
const existingPanel = document.getElementById(PANEL_ID)
if (existingPanel) {
existingPanel.remove()
return
}
const panel = document.createElement('div')
panel.id = PANEL_ID
panel.style.position = 'fixed'
panel.style.right = '12px'
panel.style.top = '72px'
panel.style.zIndex = '99999'
panel.style.width = '260px'
panel.style.maxHeight = '70vh'
panel.style.overflowY = 'auto'
panel.style.padding = '10px'
panel.style.borderRadius = '10px'
panel.style.background = 'rgba(0, 0, 0, 0.84)'
panel.style.color = '#fff'
panel.style.fontSize = '12px'
panel.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)'
const title = document.createElement('div')
title.textContent = '进度模拟'
title.style.fontWeight = 'bold'
title.style.marginBottom = '8px'
panel.appendChild(title)
const description = document.createElement('div')
description.textContent = '只写本地 puzzleStore,不调用易班保存。'
description.style.opacity = '0.75'
description.style.marginBottom = '8px'
panel.appendChild(description)
const list = document.createElement('div')
list.style.display = 'flex'
list.style.flexDirection = 'column'
list.style.gap = '6px'
list.appendChild(createButton('真实进度', () => applyMode({ type: 'real' })))
for (const residence of PUZZLE_RESIDENCES) {
list.appendChild(
createButton(`到达:${residence.displayName}`, () =>
applyMode({ type: 'arrived', residence: residence.residence, label: residence.displayName })
)
)
}
list.appendChild(createButton('全流程完成', () => applyMode({ type: 'allCompleted' })))
panel.appendChild(list)
document.body.appendChild(panel)
}
const updateBadge = () => {
let badge = document.getElementById(BADGE_ID)
if (!badge) {
badge = document.createElement('div')
badge = document.createElement('button')
badge.id = BADGE_ID
badge.style.position = 'fixed'
badge.style.right = '12px'
badge.style.top = '12px'
badge.style.zIndex = '99999'
badge.style.padding = '8px 10px'
badge.style.border = '0'
badge.style.borderRadius = '8px'
badge.style.background = 'rgba(0, 0, 0, 0.72)'
badge.style.color = '#fff'
badge.style.fontSize = '12px'
badge.style.lineHeight = '1.5'
badge.style.pointerEvents = 'none'
badge.style.cursor = 'pointer'
badge.style.whiteSpace = 'pre-line'
badge.addEventListener('click', togglePanel)
document.body.appendChild(badge)
}
badge.textContent = `${getBadgeText()}\nCtrl+Shift+U 切换`
}
const toggleUnlockAll = () => {
const nextValue = isUnlockAllEnabled() ? 'false' : 'true'
window.sessionStorage.setItem(STORAGE_KEY, nextValue)
updateBadge()
window.location.reload()
const mode = getStoredMode()
const title = mode.type === 'real' ? '测试包:真实进度' : '测试包:模拟进度'
badge.textContent = `${title}\n当前:${getModeLabel(mode)}\nCtrl+Shift+U 打开面板`
}
export const setupProgressionDebug = () => {
@@ -46,6 +242,14 @@ export const setupProgressionDebug = () => {
if (!event.ctrlKey || !event.shiftKey || event.key.toLowerCase() !== 'u') return
event.preventDefault()
toggleUnlockAll()
togglePanel()
})
window.addEventListener('click', (event) => {
const target = event.target as Node | null
if (!target) return
if (document.getElementById(PANEL_ID)?.contains(target)) return
if (document.getElementById(BADGE_ID)?.contains(target)) return
removePanel()
})
}
+110 -55
View File
@@ -1,5 +1,8 @@
import { useUserStore } from '@/store/userStore'
//超时时间
const TIMEOUT_MS = 1000 * 10
// 声明外部依赖(由运行环境提供)
declare const $: any
declare const iscallyb: boolean
@@ -9,11 +12,6 @@ declare function getFutureDa(days: number): string
declare function getCurrentDa(str: string): void
declare function formatDateFromMilliseconds(date: Date): string
// 本地或非易班环境下,外部全局变量可能未注入;统一用安全访问
const isYibanRuntime = (): boolean => {
return typeof iscallyb !== 'undefined' && iscallyb === true
}
interface UserDefineParam {
lessonId: string
key?: string | number
@@ -27,11 +25,21 @@ interface VideoAjaxResponseItem {
interface VideoAjaxResponse {
code: number
msg?: string
data: {
value?: VideoAjaxResponseItem[] | string
}
}
export type UserDefineFetchStatus = 'success' | 'empty' | 'unauthenticated' | 'error'
export interface UserDefineFetchResult {
status: UserDefineFetchStatus
content: string
code?: number
message?: string
}
interface VideoAjax {
setHost(host: string): void
addUserDefine(param: UserDefineParam): void
@@ -77,17 +85,12 @@ $.extend({
const LESSON_PREFIX = 'hsgrl'
const videoajax: VideoAjax | null = typeof videoAjax !== 'undefined' ? videoAjax() : null
videoajax?.setHost('/')
function normalizeLessonId(lessonId: string | number): string {
const raw = String(lessonId)
return raw.startsWith(`${LESSON_PREFIX}_`) ? raw : `${LESSON_PREFIX}_${raw}`
}
const videoajax: VideoAjax = videoAjax()
videoajax.setHost('/')
// 设置我的自定义数据(所有的自定义数据都设置在第一个课时ID上,以key来区分不同的类型)
function setLtDefine(key: string | number, value: string): void {
if (isYibanRuntime()) {
if (iscallyb) {
setUserDefine(1, key, value)
} else {
sessionStorage.setItem(`${LESSON_PREFIX}_${key}`, value)
@@ -98,7 +101,7 @@ let mycstr9 = ''
// 获取定义数据(由于底层是异步接口,这里返回 Promise)
async function getLtDefine(key: string | number): Promise<string> {
if (isYibanRuntime()) {
if (iscallyb) {
if (mycstr9 === '') {
const all = await getUsersDefine(1, emykey)
if (all === undefined || all === null || all === 'null' || all === '') {
@@ -142,53 +145,105 @@ export function setUserDefine(
key: string | number,
value: string
): void {
if (!isYibanRuntime() || !videoajax) {
sessionStorage.setItem(`${normalizeLessonId(lessonId)}_${key}`, value)
return
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
lessonId: `${LESSON_PREFIX}_${lessonId}`,
key,
content: value,
}
console.log(param)
videoajax.addUserDefine(param)
}
// 内部:将回调形式封装为 Promise
function getUserDefineAsyncInternal(
function getUserDefineResultAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string> {
if (!isYibanRuntime() || !videoajax) {
const value = sessionStorage.getItem(`${normalizeLessonId(lessonId)}_${key}`)
return Promise.resolve(value === null || value === 'null' ? '' : value)
): Promise<UserDefineFetchResult> {
if (!iscallyb) {
const itemValue = sessionStorage.getItem(`${LESSON_PREFIX}_${lessonId}_${key}`)
return Promise.resolve(
itemValue === undefined || itemValue === null || itemValue === 'null' || itemValue === ''
? { status: 'empty', content: '' }
: { status: 'success', content: itemValue }
)
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
lessonId: `${LESSON_PREFIX}_${lessonId}`,
order: key,
}
const ajax = videoajax
return new Promise((resolve) => {
let content = ''
ajax.getUserDefineInfoByUser(param, (res) => {
// 保留原有 console 行为
let settled = false
const done = (result: UserDefineFetchResult) => {
if (settled) return
settled = true
resolve(result)
}
const timeout = setTimeout(() => {
console.warn(`getUserDefine timeout: lessonId=${lessonId}, key=${key}`)
done({ status: 'error', content: '', message: '学习进度获取超时' })
}, TIMEOUT_MS)
videoajax.getUserDefineInfoByUser(param, (res) => {
clearTimeout(timeout)
console.log(res)
if (res && res.code === 200) {
const json = (res.data.value || []) as VideoAjaxResponseItem[]
const contents = json[0]?.content || ''
content = contents
} else {
content = ''
if (res === undefined || res === null || res === ('null' as unknown)) {
done({ status: 'empty', content: '' })
return
}
resolve(content)
if (res.code === 202) {
done({ status: 'unauthenticated', content: '', code: res.code, message: res.msg })
return
}
if (res.code !== 200) {
done({
status: 'error',
content: '',
code: res.code,
message: res.msg || '学习进度获取失败',
})
return
}
const value = res.data?.value
if (value === undefined || value === null || value === 'null' || value === '') {
done({ status: 'empty', content: '' })
return
}
const json = Array.isArray(value) ? value : []
const content = json[0]?.content || ''
done(
content === '' || content === 'null'
? { status: 'empty', content: '' }
: { status: 'success', content }
)
})
})
}
function getUserDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string> {
return getUserDefineResultAsyncInternal(lessonId, key).then((result) =>
result.status === 'success' ? result.content : ''
)
}
// Promise 版本:获取用户自定义数据
export async function getUserDefineResult(
lessonId: string | number,
key: string | number
): Promise<UserDefineFetchResult> {
return getUserDefineResultAsyncInternal(lessonId, key)
}
export async function getUserDefine(
lessonId: string | number,
key: string | number
@@ -201,19 +256,27 @@ function getUsersDefineAsyncInternal(
lessonId: string | number,
key: string | number
): Promise<string | VideoAjaxResponseItem[]> {
if (!isYibanRuntime() || !videoajax) {
return Promise.resolve('')
}
const param: UserDefineParam = {
lessonId: normalizeLessonId(lessonId),
lessonId: `${LESSON_PREFIX}_${lessonId}`,
order: key,
}
const ajax = videoajax
return new Promise((resolve) => {
let content: string | VideoAjaxResponseItem[] = ''
ajax.getUserDefineInfo(param, (data) => {
let settled = false
const done = (content: string | VideoAjaxResponseItem[]) => {
if (settled) return
settled = true
resolve(content)
}
const timeout = setTimeout(() => {
console.warn(`getUsersDefine timeout: lessonId=${lessonId}, key=${key}`)
done('')
}, TIMEOUT_MS)
videoajax.getUserDefineInfo(param, (data) => {
clearTimeout(timeout)
let content: string | VideoAjaxResponseItem[] = ''
if (data && data.code === 200) {
const json = (data.data.value || []) as VideoAjaxResponseItem[]
if (json.length > 1) {
@@ -231,10 +294,8 @@ function getUsersDefineAsyncInternal(
} else {
content = json[0]?.content || ''
}
} else {
content = ''
}
resolve(content)
done(content)
})
})
}
@@ -253,13 +314,7 @@ export async function setCourseTimeProgress(order: number): Promise<void> {
// var courseid = getCookie(lesson+"_course_id");//获取url链接的课程ID
// const courseid = await getLtDefine('10000')
console.info(`courseid:${courseid} order:${order}`)
if (
isYibanRuntime() &&
videoajax &&
courseid !== undefined &&
courseid !== null &&
courseid !== ''
) {
if (iscallyb && courseid !== undefined && courseid !== null && courseid !== '') {
if (order > 0) {
const param2 = { order, co: courseid, process: 100 }
videoajax.finishCourse(param2)
+13 -4
View File
@@ -5,11 +5,20 @@
// 敏感词库(可根据实际需求扩展)
const SENSITIVE_WORDS = [
'敏感词1',
'敏感词2',
'测试',
'垃圾',
// 可以根据实际需求添加更多敏感词
'脏话',
'骂人',
'暴力',
'恐怖',
'色情',
'赌博',
'诈骗',
'毒品',
'邪教',
'反动',
'辱骂',
'仇恨',
'sb',
]
type FileSaveHandle = {
-38
View File
@@ -2,7 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store'
if (import.meta.env.VITE_DEBUG_ENTRY === 'true' && typeof window !== 'undefined') {
import('@/debug/progressionDebug').then(({ setupProgressionDebug }) => {
@@ -29,43 +28,6 @@ if (import.meta.env.DEV && typeof window !== 'undefined') {
}
}
// 全局键盘快捷键:Ctrl+Shift+R 重置所有缓存
const setupGlobalKeyboardShortcuts = () => {
if (typeof window !== 'undefined') {
document.addEventListener('keydown', (event) => {
// Ctrl+Shift+R: 重置所有缓存
if (event.ctrlKey && event.shiftKey && event.key === 'R') {
event.preventDefault() // 阻止默认的页面刷新行为
// 显示确认对话框
const confirmed = window.confirm(
'确定要重置所有缓存吗?这将清除所有已收集的拼图碎片、已完成的展品和线索。'
)
if (confirmed) {
try {
// 重置所有store
useChenyunStore.getState().resetAll()
useHuangyanpeiStore.getState().resetAll()
usePuzzleStore.getState().resetAll()
useSunzhongshanStore.getState().resetAll()
// 显示成功消息
alert('所有缓存已重置!页面将刷新。')
window.location.reload()
} catch (error) {
console.error('重置缓存时出错:', error)
alert('重置缓存失败,请查看控制台错误信息。')
}
}
}
})
}
}
// 设置全局键盘快捷键
setupGlobalKeyboardShortcuts()
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found')
+65 -2
View File
@@ -1,14 +1,37 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
import { Image, Video } from '@/components'
import { useNavigate } from 'react-router-dom'
const Home = () => {
const navigate = useNavigate()
const [showIntroVideo, setShowIntroVideo] = useState(false)
return (
<div
className="w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('home/首页底图') }}
>
{/* 课程简介 */}
<AnimatePresence>
{!showIntroVideo && (
<motion.div
className="absolute right-0 top-0"
initial={{ opacity: 0, scale: 0.92, x: 24 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<Image
src={getStaticImage('home/课程简介')}
button
onClick={() => setShowIntroVideo(true)}
/>
</motion.div>
)}
</AnimatePresence>
<Image
onClick={() => {
navigate('/menu')
@@ -17,6 +40,46 @@ const Home = () => {
button
src={getStaticImage('home/首页-进入')}
/>
<AnimatePresence>
{showIntroVideo && (
<motion.div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/65"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.28, ease: 'easeInOut' }}
>
<motion.div
className="relative w-full h-full overflow-hidden bg-black"
initial={{ opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.32, ease: [0.25, 0.46, 0.45, 0.94] }}
>
<Video
className="w-full h-full"
src={getPublicPath('video/intro.mp4')}
autoPlay
controls
/>
<motion.div
className="absolute right-0 top-0 z-10"
initial={{ opacity: 0, scale: 0.92, x: 24 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.92, x: 24 }}
transition={{ duration: 0.22, ease: 'easeOut', delay: 0.08 }}
>
<Image
src={getStaticImage('home/点击关闭')}
button
onClick={() => setShowIntroVideo(false)}
/>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
+32 -10
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useChenyunStore, CHENYUN_LESSON_ID, CHENYUN_KEY } from '@/store/chenyunStore'
const deserializeVisitedExhibits = (
@@ -43,10 +44,26 @@ const Chenyun = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(CHENYUN_LESSON_ID, CHENYUN_KEY)
if (!raw) return
const result = await getUserDefineResult(CHENYUN_LESSON_ID, CHENYUN_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
isYinyuanSolved?: boolean
isMimianSolved?: boolean
@@ -56,14 +73,19 @@ const Chenyun = () => {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
isYinyuanSolved:
data.isYinyuanSolved !== undefined ? data.isYinyuanSolved : prev.isYinyuanSolved,
isMimianSolved:
data.isMimianSolved !== undefined ? data.isMimianSolved : prev.isMimianSolved,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
isYinyuanSolved: data.isYinyuanSolved ?? false,
isMimianSolved: data.isMimianSolved ?? false,
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useChenyunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
isYinyuanSolved: false,
isMimianSolved: false,
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Chenyun store', error)
}
})()
+5 -5
View File
@@ -84,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-3',
time: 16 * 60 + 31, //16:26
time: 15 * 60 + 56, //15:56
title: '题目3',
prompt: '新时代的青年可以从陈云身上学习到',
type: 'drag',
@@ -117,7 +117,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-5',
time: 60 * 16 + 23, // 16:23
time: 60 * 15 + 48, // 15:48
title: '题目5',
prompt: '以下哪些陈云的生活小事值得我们学习?',
type: 'multiple',
@@ -219,7 +219,7 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
title="恭喜你, 选择正确!"
title="恭喜你, 选择正确!"
content=""
onContinue={() => {
setActiveSuccess(null)
@@ -291,7 +291,7 @@ const ChenyunInternalization = () => {
/>
<SuccessAlert
visible={activeSuccess === item.id}
// title="恭喜你, 选择正确!"
// title="恭喜你, 选择正确!"
// content="回答正确,请继续探索。"
onContinue={() => {
setActiveSuccess(null)
@@ -496,7 +496,7 @@ const ChenyunInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(24)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'chenyun' } })
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_40.mp3')}
/>
@@ -9,7 +9,7 @@ import type { ClueData } from '../clue/types'
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath('video/chenyun/chenyun_15.mp4'),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。”你将化身陈云同志的特派员,直面这场“银元之战”。`,
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。”上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。你将化身陈云同志的特派员,直面这场“银元之战”。`,
alert: {
title: '第一幕:“银元之战”--人民币的阵地争夺战',
subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
+4 -3
View File
@@ -35,9 +35,10 @@ const ChenyunReflection = () => {
// ========== 顶层状态管理 ==========
/** 当前展开的文件 */
const [expandedFile, setExpandedFile] = useState<FileType | null>(() =>
useChenyunStore.getState().isYinyuanSolved ? 'mimian' : null
)
const [expandedFile, setExpandedFile] = useState<FileType | null>(() => {
const { isYinyuanSolved, isMimianSolved } = useChenyunStore.getState()
return isYinyuanSolved && !isMimianSolved ? 'mimian' : null
})
/** 当前播放的视频 */
const [playingVideo, setPlayingVideo] = useState<FileType | null>(null)
@@ -85,7 +85,7 @@ export const MimianBattle = ({
state.setShowMimianQuiz2(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent(
`结合当时市场情况,陈云同志在会上做出了"全国一盘棋"的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
`结合当时市场情况,陈云同志在会上做出了“全国一盘棋”的指示。全国统筹调运粮食、棉花、煤炭填补缺口,筑牢物资基础。关键节点集中抛售,让跟风囤货的投机商无力承接,因亏本和高利贷纷纷破产,快速平抑物价、稳固人民币地位。`
)
}
// 选错时,错误信息已经在题目面板中通过 onWrongSelect 显示,不需要额外的 SuccessAlert
@@ -125,7 +125,7 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz3(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent('地方配合,调运按时推进。')
state.setMimianSuccessAlertContent('地方配合,调运按时推进。')
}
}
@@ -147,7 +147,7 @@ export const MimianBattle = ({
if (isCorrect) {
state.setShowMimianQuiz4(false)
state.setShowMimianSuccessAlert(true)
state.setMimianSuccessAlertContent('棉农支持,调运量足额完成。')
state.setMimianSuccessAlertContent('棉农支持,调运量足额完成。')
} else {
// state.setShowMimianQuiz4(false);
// state.setShowMimianSuccessAlert(true);
@@ -176,16 +176,16 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题3(图5) */}
<SingleChoiceQuizPanel
visible={showQuiz3}
title="【单选】某地方粮站反馈“本地冬储粮不足,不愿外运”。"
title="【单选】华东某地方粮站反馈“本地冬储粮不足,不愿外运”。"
options={[
{ value: 'A', label: 'A. 强制调运,否则追责' },
{
value: 'B',
label: 'B. 中央已协调东北补充常州冬储粮,先保障全国物价稳定',
label: 'B. 中央已协调东北补充华东地方冬储粮,先保障全国物价稳定',
},
]}
correctAnswer="B"
errorMessage="地方抵触,调运延误多天。请你再思考一下吧!"
errorMessage="地方抵触,调运延误多天。请你再思考一下吧!"
onSubmit={onQuiz3Submit}
/>
@@ -245,7 +245,7 @@ export const MimianQuizzes = ({
{/* 米棉之战 - 单选题5(图8) */}
<SingleChoiceQuizPanel
visible={showQuiz5}
title="【单选】此时,以下哪种举措能够一举获得胜利?"
title="【单选】此时,以下哪种举措能够一举获得胜利?"
options={[
{
value: 'A',
@@ -254,7 +254,7 @@ export const MimianQuizzes = ({
},
{
value: 'B',
label: `B.逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图"劝退"投机商`,
label: `B.逐步降价,配合舆论引导,每日小幅降价,并通过报纸宣传政府物资充足,试图“劝退”投机商`,
},
]}
correctAnswer="A"
+2 -2
View File
@@ -32,7 +32,7 @@ const entranceActionConfig: Record<
locked: {
image: 'entrance/暂未解锁',
position: {
left: 'absolute left-95 bottom-58',
left: 'absolute left-108 bottom-58',
right: 'absolute right-106 bottom-58',
},
},
@@ -47,7 +47,7 @@ const entranceActionConfig: Record<
image: 'entrance/该故居已访问',
position: {
left: 'absolute left-95 bottom-58',
right: 'absolute right-102 bottom-58',
right: 'absolute right-98 bottom-58',
},
},
}
+49 -13
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import {
useHuangyanpeiStore,
HUANGYANPEI_LESSON_ID,
@@ -37,28 +38,63 @@ const Huangyanpei = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
if (!raw) return
const result = await getUserDefineResult(HUANGYANPEI_LESSON_ID, HUANGYANPEI_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
shownReflectionCompletionGuides?: Record<string, boolean>
reflectionScene2Choice?: '铁工科' | '商科' | null
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useHuangyanpeiStore.setState((prev) => ({
...prev,
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3', 'reflection4'].includes(
data.reflectionRoute
)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
visitedExhibits: Object.fromEntries(
Object.entries(data.visitedExhibits ?? {}).map(([place, exhibits]) => [
place,
new Set(Array.isArray(exhibits) ? exhibits : []),
])
),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
shownReflectionCompletionGuides: data.shownReflectionCompletionGuides ?? {},
reflectionScene2Choice: data.reflectionScene2Choice ?? null,
}))
} catch (error) {
useHuangyanpeiStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
shownReflectionCompletionGuides: {},
reflectionScene2Choice: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Huangyanpei store', error)
}
})()
+1 -1
View File
@@ -546,7 +546,7 @@ const HuangyanpeiInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(12)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'huangyanpei' } })
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_31.mp3')}
/>
+1 -1
View File
@@ -345,7 +345,7 @@ const HuangyanpeiPlace3 = () => {
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_07.mp3')}
>
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在学习的课程有专业课、实习实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在的专业课、实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
+1 -1
View File
@@ -342,7 +342,7 @@ const HuangyanpeiPlace4 = () => {
{/* 题目 */}
<SingleChoiceQuizPanel
visible={showQuiz}
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
options={[
{ value: 'A', label: 'A. 学而优则仕' },
{ value: 'B', label: 'B. 双手万能、手脑并用' },
+2 -2
View File
@@ -150,13 +150,13 @@ const HuangyanpeiPlace6 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21_2.mp3')}
>
第一次把”中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家”一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。
第一次把“中等专业学校”正式纳入国家学制,让职业教育有了明确的国家定位。1952年,他又推动建立了上海机械学校、南京航空学校等五所中专,这些学校为国家“一五”计划输送了6.3万名技术工人,为工业发展提供了坚实的人才支撑。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_21_3.mp3')}
>
黄炎培先生曾说过:”我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的”工学结合、德技并修”,正是对他”手脑并用、注重实践”理念的传承和发展。
黄炎培先生曾说过:“我要把最后一滴血,滴在职业教育的讲台上。”他用一生践行了这句话,而我们现在中职教育强调的“工学结合、德技并修”,正是对他“手脑并用、注重实践”理念的传承和发展。
</GuideStepsItem>
</GuideSteps>
+2 -2
View File
@@ -234,7 +234,7 @@ const HuangyanpeiReflectionScene2 = () => {
<SuccessAlert
visible={showQuizSuccess}
title=""
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
content="恭喜你!选择正确。不愧是从川沙小学堂走出来!"
onClose={() => {
setShowQuizSuccess(false)
}}
@@ -246,7 +246,7 @@ const HuangyanpeiReflectionScene2 = () => {
onContinue={handleCompleteGuideContinue}
verticalAlign="center"
>
第二关完成!“职业方向锁定!”去新学校了解你的专业吧。
第二关完成!职业方向锁定!去新学校了解你的专业吧。
</StudyGuideModal>
</div>
)
@@ -175,7 +175,7 @@ const ChoiceDialog2 = ({ visible = true, onClose, onSelect }: ChoiceDialog2Props
<SuccessAlert
visible={showSuccessAlert}
title="选得好!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
content="不管是做工匠还是做买卖,学好本事就能报效国家,这个选择会影响你在中华职业学校的专业哦!"
onClose={handleSuccessAlertContinue}
/>
</div>
@@ -114,7 +114,7 @@ const Scene2QuizDialog = ({ visible = true, onClose, onComplete }: Scene2QuizDia
<div className="flex-1">
<div className="text-5xl font-bold mb-6 -ml-[26px]">【单选】</div>
<div className="text-4xl leading-relaxed">
杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧?
杨斯盛先生近期监工浦东中学建设,有一份上月的工人工钱支出表需要核对,你来告诉我答案吧!
</div>
</div>
@@ -11,6 +11,7 @@ import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
const starName = 'huangyanpei'
@@ -20,6 +21,8 @@ const HuangyanpeiReflectionScene3 = () => {
moduleName: string
}>()
const { triggerPuzzleAnimation } = usePuzzleAnimation()
//喇叭考题弹窗
const [showQuizDialog, setShowQuizDialog] = useState(false)
@@ -252,6 +255,7 @@ const HuangyanpeiReflectionScene3 = () => {
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
triggerPuzzleAnimation(15)
}}
/>
</div>
+2 -3
View File
@@ -8,10 +8,9 @@ import TowelSalesDialog from './scene4/TowelSalesDialog'
import type { VideoRef } from '@/components/video'
import { QuizDialog } from './components'
import { usePuzzleAnimation } from '@/contexts/PuzzleAnimationContext'
import { useHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { useHuangyanpeiStore, saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
import { setCourseTimeProgress } from '@/lib/utils/api-common'
import { useCursorController } from '@/hooks/useCursorController'
import { saveHuangyanpeiStore } from '@/store/huangyanpeiStore'
const starName = 'huangyanpei'
@@ -186,7 +185,6 @@ const HuangyanpeiReflectionScene4 = () => {
onComplete={() => {
// 完成拖拽后更新visited状态
markReflectionCursorVisited(scene, 2)
triggerPuzzleAnimation(15)
}}
/>
@@ -238,6 +236,7 @@ const HuangyanpeiReflectionScene4 = () => {
onComplete={() => {
setShowQuizDialog(false)
markReflectionCursorVisited(scene, 3)
triggerPuzzleAnimation(15)
}}
/>
@@ -91,19 +91,19 @@ const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDia
() => [
{
value: 'option1',
label: 'A.这是中华职校生产的国货,质量比洋货还好!',
label: 'A.这是中华职校生产的国货,质量比洋货还好!',
},
{
value: 'option2',
label: 'B.价格便宜,量大从优,诚信经营绝不掺假',
label: 'B.价格便宜,量大从优,诚信经营绝不掺假。',
},
{
value: 'option3',
label: 'C.买国货就是支持实业救国,为国家出一份力!',
label: 'C.买国货就是支持实业救国,为国家出一份力!',
},
{
value: 'option4',
label: 'D.我只管赚钱,东西好不好不管哦',
label: 'D.我只管赚钱,东西好不好不管哦。',
},
],
[]
+149 -35
View File
@@ -1,24 +1,22 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image } from '@/components'
import { isResidenceComplete, isResidenceUnlocked } from '@/config/progression'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
const residences = [
{ key: 'maozedong', name: '毛泽东', left: '30%', top: '35%' },
{ key: 'sunzhongshan', name: '孙中山', left: '12%', top: '51%' },
{ key: 'luxun', name: '鲁迅', left: '26.3%', top: '74%' },
{ key: 'huangyanpei', name: '黄炎培', left: '44%', top: '67%' },
{ key: 'nieer', name: '聂耳', left: '62%', top: '60%' },
{ key: 'zhangleping', name: '张乐平', left: '73%', top: '33%' },
{ key: 'rongdesheng', name: '荣德生', left: '90%', top: '45%' },
{ key: 'chenyun', name: '陈云', left: '80%', top: '76%' },
]
const SPOTLIGHT_PROMPT_STORAGE_PREFIX = 'learning-map:spotlight-clicked:'
interface HexagonProgressBarProps {
progress: number
}
interface LearningMapLocationState {
completedResidence?: string
}
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const percent = Math.min(100, Math.max(0, progress))
const text = percent >= 100 ? '进度100%' : `进度${Math.round(percent)}%`
@@ -51,13 +49,82 @@ const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const LearningMap = () => {
const navigate = useNavigate()
const location = useLocation()
const puzzles = usePuzzleStore((s) => s.puzzles)
const isLoaded = usePuzzleStore((s) => s.isLoaded)
const getPuzzleCollectProgress = usePuzzleStore((s) => s.getPuzzleCollectProgress)
const completedResidence = (location.state as LearningMapLocationState | null)?.completedResidence
const [spotlightPromptResidence, setSpotlightPromptResidence] = useState<string | null>(null)
const progressMap = useMemo(
() => Object.fromEntries(residences.map((r) => [r.key, getPuzzleCollectProgress(r.key)])),
[getPuzzleCollectProgress]
const progressMap = Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded ? getPuzzleCollectProgress(residence) : 0,
])
)
const unlockedMap = useMemo(
() =>
Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded && isResidenceUnlocked(residence, puzzles),
])
),
[isLoaded, puzzles]
)
const completeMap = useMemo(
() =>
Object.fromEntries(
PUZZLE_RESIDENCES.map(({ residence }) => [
residence,
isLoaded && isResidenceComplete(residence, puzzles),
])
),
[isLoaded, puzzles]
)
const newlyUnlockedResidence = useMemo(() => {
if (!completedResidence) return null
const completedIndex = PUZZLE_RESIDENCES.findIndex(
(item) => item.residence === completedResidence
)
const nextResidence = PUZZLE_RESIDENCES[completedIndex + 1]?.residence
if (!nextResidence || !unlockedMap[nextResidence]) return null
return nextResidence
}, [completedResidence, unlockedMap])
useEffect(() => {
if (!completedResidence || !isLoaded || !completeMap[completedResidence]) {
setSpotlightPromptResidence(null)
return
}
const isKnownResidence = PUZZLE_RESIDENCES.some((item) => item.residence === completedResidence)
const isPromptClicked =
window.sessionStorage.getItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${completedResidence}`) ===
'true'
setSpotlightPromptResidence(isKnownResidence && !isPromptClicked ? completedResidence : null)
}, [completeMap, completedResidence, isLoaded])
const handleSpotlightClick = (residence: string, isUnlocked: boolean) => {
if (!isUnlocked) return
if (residence === spotlightPromptResidence) {
window.sessionStorage.setItem(`${SPOTLIGHT_PROMPT_STORAGE_PREFIX}${residence}`, 'true')
setSpotlightPromptResidence(null)
}
navigate(`/map/spotlight/${residence}`)
}
const handleResidenceClick = (moduleName: string, residence: string, isUnlocked: boolean) => {
if (!isUnlocked) return
navigate(`/${moduleName}/${residence}/home`)
}
return (
<div
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
@@ -78,28 +145,75 @@ const LearningMap = () => {
/>
<div className="absolute inset-0">
{residences.map(({ key, name, left, top }) => (
<div
key={key}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left, top }}
>
<Image src={getStaticImage(`learning-map/${name}`)} alt={name} />
{PUZZLE_RESIDENCES.map(({ residence, image, moduleName, left, top }) => {
const isUnlocked = unlockedMap[residence]
const isNewlyUnlocked = residence === newlyUnlockedResidence
const showSpotlightPrompt = residence === spotlightPromptResidence
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progressMap[key] ?? 0} />
</div>
return (
<motion.div
key={residence}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left, top }}
initial={
isNewlyUnlocked
? { opacity: 0.4, filter: 'grayscale(1) brightness(0.8)' }
: undefined
}
animate={{
opacity: isUnlocked ? 1 : 0.4,
filter: isUnlocked ? 'grayscale(0) brightness(1)' : 'grayscale(1) brightness(0.8)',
}}
transition={
isNewlyUnlocked
? { duration: 1.2, ease: 'easeOut' }
: { duration: 0.25, ease: 'easeOut' }
}
>
<motion.div
animate={
isNewlyUnlocked
? { filter: ['brightness(1)', 'brightness(1.25)', 'brightness(1)'] }
: undefined
}
transition={
isNewlyUnlocked ? { delay: 1.2, duration: 1, ease: 'easeInOut' } : undefined
}
>
<Image
className={isUnlocked ? undefined : 'cursor-not-allowed'}
src={getStaticImage(`learning-map/${image}`)}
alt={image}
button={isUnlocked}
onClick={() => handleResidenceClick(moduleName, residence, !!isUnlocked)}
/>
<div className="flex justify-center gap-6">
<Image
src={getStaticImage('learning-map/追光入口')}
alt="追光"
button
onClick={() => navigate(`/map/spotlight/${key}`)}
/>
</div>
</div>
))}
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progressMap[residence] ?? 0} />
</div>
<div className="relative flex justify-center gap-6">
{showSpotlightPrompt && (
<motion.img
className="absolute -right-10 -top-14 z-20 w-20 pointer-events-none"
src={getStaticImage('learning-map/箭头指引')}
alt="点击追光"
animate={{ y: [0, -8, 0], scale: [1, 1.08, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: 'easeInOut' }}
/>
)}
<Image
className={isUnlocked ? undefined : 'cursor-not-allowed'}
src={getStaticImage('learning-map/追光入口')}
alt="追光"
button={isUnlocked}
onClick={() => handleSpotlightClick(residence, !!isUnlocked)}
/>
</div>
</motion.div>
</motion.div>
)
})}
</div>
</div>
)
+18 -14
View File
@@ -1,30 +1,34 @@
import { useState } from 'react'
import { Navigate, useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { isResidenceUnlocked } from '@/config/progression'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { getStaticImage } from '@/lib/utils'
const residenceNameMap: Record<string, string> = {
maozedong: '毛泽东',
sunzhongshan: '孙中山',
luxun: '鲁迅',
huangyanpei: '黄炎培',
nieer: '聂耳',
zhangleping: '张乐平',
rongdesheng: '荣德生',
chenyun: '陈云',
}
import { usePuzzleStore } from '@/store/puzzleStore'
const SpotlightMap = () => {
const navigate = useNavigate()
const { residence } = useParams()
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
const [showDetail, setShowDetail] = useState(false)
const residenceName = residence ? residenceNameMap[residence] : undefined
const residenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence)
if (!residenceName) {
if (!residenceConfig) {
return <Navigate to="/learning-map" replace />
}
if (!isLoaded) {
return <div className="relative w-full h-full bg-black" />
}
if (!isResidenceUnlocked(residenceConfig.residence, puzzles)) {
return <Navigate to="/learning-map" replace />
}
const residenceName = residenceConfig.image
return (
<div className="relative w-full h-full bg-black">
<Image
@@ -51,7 +55,7 @@ const SpotlightMap = () => {
onClick={() => setShowDetail(true)}
></div>
<Image
className="pointer-events-none transition-transform duration-200 group-hover:scale-110"
className="mt-58 pointer-events-none transition-transform duration-200 group-hover:scale-110"
src={getStaticImage('learning-map/spotlight/点击')}
alt="详情"
/>
+35 -11
View File
@@ -4,7 +4,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { getUserDefine } from '@/lib/utils/api-common'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { useLuxunStore, LUXUN_LESSON_ID, LUXUN_KEY } from '@/store/luxunStore'
const deserializeVisitedExhibits = (
@@ -44,27 +45,50 @@ const Luxun = () => {
useEffect(() => {
;(async () => {
try {
const raw = await getUserDefine(LUXUN_LESSON_ID, LUXUN_KEY)
if (!raw) return
const result = await getUserDefineResult(LUXUN_LESSON_ID, LUXUN_KEY)
const data = JSON.parse(raw) as {
if (result.status !== 'success') {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
reflectionRoute?: string
}
const validReflectionRoute =
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: 'reflection'
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace:
data.lastVisitedPlace !== undefined ? data.lastVisitedPlace : prev.lastVisitedPlace,
reflectionRoute:
data.reflectionRoute &&
['reflection', 'reflection2', 'reflection3'].includes(data.reflectionRoute)
? data.reflectionRoute
: (prev.reflectionRoute ?? 'reflection'),
lastVisitedPlace: data.lastVisitedPlace ?? null,
reflectionRoute: validReflectionRoute,
}))
} catch (error) {
useLuxunStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
reflectionRoute: 'reflection',
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Luxun store', error)
}
})()
+2 -2
View File
@@ -551,9 +551,9 @@ const LuxunInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(9)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'luxun' } })
}}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_38.mp3')}
audioSrc={getPublicPath('audio/luxun/mp3_luxun_33_02_new.mp3')}
/>
</div>
)
+1 -1
View File
@@ -322,7 +322,7 @@ const LuxunPlace3 = () => {
{/* 光标3的题目 */}
<SingleChoiceQuizPanel
visible={quiz2Visible}
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
title="【单选】鲁迅在上海大陆新村9号居住期间,翻译外国作品的核心目的不包括:"
options={[
{
value: 'A',
+1 -1
View File
@@ -343,7 +343,7 @@ const LuxunPlace5 = () => {
zIndex={10000}
>
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/luxun/mp3_luxun_28.mp3')}>
除了瞿秋白,还有其他共产党人来过这里吗
除了瞿秋白,还有其他共产党人来过这里吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/luxun/mp3_luxun_29_1.mp3')}>
当然!1936年4月,中共中央特派员冯雪峰到上海开展工作,也住进了这间客房。冯雪峰把红军长征胜利、遵义会议召开的消息告诉鲁迅,还带来了毛泽东同志提出的抗日民族统一战线主张。
+56 -1
View File
@@ -4,7 +4,20 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useMaozedongStore } from '@/store/maozedongStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { MAOZEDONG_KEY, MAOZEDONG_LESSON_ID, useMaozedongStore } from '@/store/maozedongStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const Maozedong = () => {
const navigate = useNavigate()
@@ -27,6 +40,48 @@ const Maozedong = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(MAOZEDONG_LESSON_ID, MAOZEDONG_KEY)
if (result.status !== 'success') {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
}
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useMaozedongStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Maozedong store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+1 -1
View File
@@ -514,7 +514,7 @@ const MaozedongInternalization = () => {
onContinue={() => {
setShowCompleted(false)
setCourseTimeProgress(3)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'maozedong' } })
}}
/>
</div>
+2 -1
View File
@@ -171,7 +171,8 @@ const MaozedongPlace1 = () => {
那参加中共一大时,毛主席对未来有什么想法吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/maozedong/mp3_mzd_04.mp3')}>
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的”土壤”就是农民占大多数,这是和苏联不一样的地方。
当时全国只有50多个党员,毛主席所在的湖南也才几个。但他觉得,重要的是把马克思主义和中国的实际情况结合起来。就像种田,种子再好,也得看土壤——中国的
“土壤”就是农民占大多数,这是和苏联不一样的地方。
</GuideStepsItem>
</GuideSteps>
+1 -1
View File
@@ -494,7 +494,7 @@ const MaozedongPlace4 = () => {
onSubmit={(_value, isCorrect) => {
if (!isCorrect) return
setQuizVisible(false)
setSuccessAlertContent('中共四大召开于1925年1月11日至22日,在上海。')
setSuccessAlertContent('中共四大于1925年1月11日至22日在上海召开。')
setSuccessAlertVisible(true)
}}
/>
+6 -6
View File
@@ -66,7 +66,7 @@ const endingQuizOptions = [
},
{
value: 'B',
label: 'B. 在调养修整后调研农民运动',
label: 'B. 在调养休整后调研农民运动',
},
]
@@ -232,7 +232,7 @@ const Home = () => {
>
{`同学你好,欢迎来到“跨越时空·体悟”学习模块!
请登陆“时空回溯・亲历时代”体验仓,下一站:1924年,上海。
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在筹备关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
1924年的上海,革命浪潮暗涌,中共四大筹备工作关乎革命前途。无产阶级领导权、工农联盟等核心议题亟待明确,毛泽东同志在会议筹备的关键节点面临诸多重要抉择。请你化身历史见证者,站在毛主席的视角做出选择,解锁中共四大筹备的关键密码,感受革命先辈的坚定与智慧!完成三次选择后,可进入下一阶段的学习。时空门已开,请做好准备迎接考验吧!`}
</StudyGuideModal>
),
},
@@ -431,7 +431,7 @@ const Home = () => {
},
{
id: 'anchor-12',
time: 60 * 2 + 43,
time: 60 * 2 + 44,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
@@ -445,7 +445,7 @@ const Home = () => {
},
{
id: 'anchor-13',
time: 60 * 2 + 57,
time: 60 * 2 + 58,
afterResume: 'hide',
render: ({ resume, unmute }) => (
<Cursor
@@ -459,7 +459,7 @@ const Home = () => {
},
{
id: 'anchor-14',
time: 60 * 3 + 9,
time: 60 * 3 + 10,
afterResume: 'hide',
render: ({ resume, unmute }) => <EndingQuizCursor resume={resume} unmute={unmute} />,
},
@@ -487,7 +487,7 @@ const Home = () => {
visible={endingVisible}
audioSrc={getPublicPath('audio/maozedong/mp3_mzd_23.mp3')}
animationSpeed={200}
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因健康未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
text="毛泽东在中共四大筹备期间的三次重要抉择,均锚定真实实践、扎根基层,为四大首次明确无产阶级领导权、工农联盟筑牢基础。虽因身体健康原因未参会,但其筹备工作与后续调研,如纽带般连接理论与实践,让党的革命理念在历史浪潮中,朝着正确方向坚定前行,成为早期共产党人探索革命道路的生动注脚,影响深远,照耀后世奋进征程。"
onContinue={() => {
setEndingVisible(false)
saveMaozedongStore()
+46 -77
View File
@@ -1,5 +1,4 @@
import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
import { createPortal } from 'react-dom'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
export type SelectBgOption = {
@@ -54,36 +53,17 @@ export function SelectBg({
}: SelectBgProps) {
const [isOpen, setIsOpen] = useState(false)
const [hoveredValue, setHoveredValue] = useState<string | null>(null)
const [dropdownPosition, setDropdownPosition] = useState<{
top: number
left: number
width: number
} | null>(null)
const triggerRef = useRef<HTMLDivElement | null>(null)
const dropdownRef = useRef<HTMLDivElement | null>(null)
const selectedOption = useMemo(() => options.find((opt) => opt.value === value), [options, value])
const updateDropdownPosition = useCallback(() => {
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
setDropdownPosition({
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
})
}, [])
const close = useCallback(() => setIsOpen(false), [])
const open = useCallback(() => {
if (disabled) return
setIsOpen(true)
// 等到下一帧确保布局稳定再计算位置
setTimeout(() => updateDropdownPosition(), 0)
}, [disabled, updateDropdownPosition])
}, [disabled])
const handleSelect = useCallback(
(nextValue: string) => {
@@ -110,17 +90,12 @@ export function SelectBg({
}
}
const onScrollOrResize = () => updateDropdownPosition()
document.addEventListener('mousedown', onMouseDown)
window.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
return () => {
document.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
}, [close, isOpen, updateDropdownPosition])
}, [close, isOpen])
return (
<div className={cn('relative inline-block w-[875px]', className)}>
@@ -157,56 +132,50 @@ export function SelectBg({
</span>
</div>
{isOpen &&
dropdownPosition &&
createPortal(
<div
ref={dropdownRef}
className={cn('fixed bg-transparent', dropdownClassName)}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
zIndex,
transform: 'translateY(-100%)',
}}
role="listbox"
>
<div className="flex flex-col">
{options.map((opt) => {
const isHovered = opt.value === hoveredValue
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={!!opt.disabled}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setHoveredValue(opt.value)}
onMouseLeave={() => setHoveredValue(null)}
className={cn(
'h-[30px] bg-cover bg-no-repeat flex items-center justify-center',
opt.disabled && 'cursor-not-allowed opacity-60 pointer-events-none',
optionClassName
)}
style={{
backgroundImage: getBackgroundImage(
isHovered ? selectedBgPath : unselectedBgPath
),
backgroundSize: '100% 100%',
}}
>
<span className="pointer-events-none text-[#756D5C] text-lg leading-none font-medium">
{opt.label}
</span>
</button>
)
})}
</div>
</div>,
document.body
)}
{isOpen && (
<div
ref={dropdownRef}
className={cn(
'absolute bottom-full left-0 z-10 w-full bg-transparent',
dropdownClassName
)}
style={{ zIndex }}
role="listbox"
>
<div className="flex flex-col">
{options.map((opt) => {
const isHovered = opt.value === hoveredValue
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={!!opt.disabled}
onClick={() => handleSelect(opt.value)}
onMouseEnter={() => setHoveredValue(opt.value)}
onMouseLeave={() => setHoveredValue(null)}
className={cn(
'h-[56px] bg-cover bg-no-repeat flex items-center justify-center',
opt.disabled && 'cursor-not-allowed opacity-60 pointer-events-none',
optionClassName
)}
style={{
backgroundImage: getBackgroundImage(
isHovered ? selectedBgPath : unselectedBgPath
),
backgroundSize: '100% 100%',
}}
>
<span className="pointer-events-none max-w-full px-4 text-[26px] text-[#756D5C] text-lg leading-none font-medium whitespace-nowrap overflow-hidden text-ellipsis">
{opt.label}
</span>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
+56 -1
View File
@@ -4,7 +4,20 @@ import { useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { usePuzzleStore } from '@/store/puzzleStore'
import { getResidenceStageUnlocks, isResidenceUnlocked } from '@/config/progression'
import { useNieerStore } from '@/store/nieerStore'
import { getUserDefineResult } from '@/lib/utils/api-common'
import { showYibanSyncErrorPrompt, showYibanUnauthenticatedPrompt } from '@/components'
import { NIEER_KEY, NIEER_LESSON_ID, useNieerStore } from '@/store/nieerStore'
const deserializeVisitedExhibits = (
data: Record<string, number[]> | undefined
): Record<string, Set<number>> => {
if (!data || typeof data !== 'object') return {}
const visitedExhibits: Record<string, Set<number>> = {}
for (const [place, exhibits] of Object.entries(data)) {
visitedExhibits[place] = new Set(Array.isArray(exhibits) ? exhibits : [])
}
return visitedExhibits
}
const NieerHome = () => {
const navigate = useNavigate()
@@ -25,6 +38,48 @@ const NieerHome = () => {
}
}, [isLoaded, navigate, puzzles, starName])
useEffect(() => {
;(async () => {
try {
const result = await getUserDefineResult(NIEER_LESSON_ID, NIEER_KEY)
if (result.status !== 'success') {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
if (result.status === 'unauthenticated') {
showYibanUnauthenticatedPrompt()
} else if (result.status === 'error') {
showYibanSyncErrorPrompt()
}
return
}
const data = JSON.parse(result.content) as {
visitedExhibits?: Record<string, number[]>
lastVisitedPlace?: string | null
}
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: deserializeVisitedExhibits(data.visitedExhibits),
lastVisitedPlace: data.lastVisitedPlace ?? null,
}))
} catch (error) {
useNieerStore.setState((prev) => ({
...prev,
visitedExhibits: {},
lastVisitedPlace: null,
}))
showYibanSyncErrorPrompt()
console.error('Failed to load Nieer store', error)
}
})()
}, [])
if (!moduleName) return null
return (
+3 -3
View File
@@ -309,10 +309,10 @@ const NieerInternalization = () => {
<GuideStepsItem speaker="student" audioSrc={getPublicPath('audio/nieer/mp3_nieer_37.mp3')}>
小沪,前两个模块中,我循着聂耳的足迹读懂了旋律背后的家国情怀,亲历了国歌跨越时空的动人故事。我还想从音乐这一艺术创作角度出发更多了解聂耳,你能给我讲讲吗?
</GuideStepsItem>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38.mp3')}>
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38_1.mp3')}>
讲到音乐,可是很专业的领域了!今天我们有幸邀请到了上海音乐学院党委宣传部副部长,东方乐器博物馆馆长,研究员,肖阳。肖老师会从上海音乐学院对聂耳的研究、与《义勇军进行曲》的渊源的角度告诉你聂耳的音乐作品与时代价值。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem speaker="guide" audioSrc={getPublicPath('audio/nieer/mp3_nieer_38_2.mp3')}>
接下来,就让我们跟随肖老师一起,探寻聂耳音乐作品和艺术精神的当代意义吧!
</GuideStepsItem>
</GuideSteps>
@@ -378,7 +378,7 @@ const NieerInternalization = () => {
onContinue={() => {
setShowStudyGuide(false)
setCourseTimeProgress(15)
navigate('/menu')
navigate('/learning-map', { state: { completedResidence: 'nieer' } })
}}
/>
</div>
+1 -1
View File
@@ -393,7 +393,7 @@ const NieerPlace1 = () => {
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正位立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
此刻你正伫立于国歌展示馆的入口,请随时光的脚步,在聂耳作品的“时光声浪”中聆听5段炽热的时代回响:民族危亡的怒吼声、时代呼唤的最强音、民族解放的冲锋号、强我中华的进行曲、伟大复兴的主旋律。你准备好跟随聂耳的音符,踏上这段炽热征程了吗?
</StudyGuideModal>
</div>
)
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more