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
+1886 -654

No files matched your search

+2 -1
View File
@@ -58,7 +58,8 @@
"Bash(git config *)",
"WebSearch",
"Bash(grep -v \"^--$\")",
"Bash(grep -E \"^\\\\S+.*>.*</GuideStepsItem>$\")"
"Bash(grep -E \"^\\\\S+.*>.*</GuideStepsItem>$\")",
"Bash(git -C \"/Users/zhongyidian/工作/育享/沪上又见故人来/shanghai-residences/.claude/worktrees/agent-ab7540fc239364906\" status --short)"
],
"deny": [
"Read(public/**)",
+1
View File
@@ -0,0 +1 @@
VITE_DEBUG_ENTRY=false
+1
View File
@@ -0,0 +1 @@
VITE_DEBUG_ENTRY=false
+1
View File
@@ -0,0 +1 @@
VITE_DEBUG_ENTRY=true
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+41 -5
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) {
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

+122 -19
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,44 +172,132 @@ 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/引导框')}
alt="学习引导背景"
className="absolute inset-0 w-full h-full object-contain"
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="pointer-events-none absolute left-10 top-16 z-20"
/>
{/* 星星 */}
<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"
>
<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-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto',
'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'
)}
>
{/* {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-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={{
marginTop: verticalAlign === 'center' ? '85px' : '',
paddingLeft:
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
}}
@@ -204,9 +306,10 @@ export const StudyGuideModal = ({
</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 行为
console.log(res)
if (res && res.code === 200) {
const json = (res.data.value || []) as VideoAjaxResponseItem[]
const contents = json[0]?.content || ''
content = contents
} else {
content = ''
let settled = false
const done = (result: UserDefineFetchResult) => {
if (settled) return
settled = true
resolve(result)
}
resolve(content)
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 === undefined || res === null || res === ('null' as unknown)) {
done({ status: 'empty', content: '' })
return
}
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 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[] = ''
ajax.getUserDefineInfo(param, (data) => {
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>
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more