fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No files matched your search
@@ -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/**)",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
VITE_DEBUG_ENTRY=false
|
||||
@@ -0,0 +1 @@
|
||||
VITE_DEBUG_ENTRY=false
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 2.8 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 491 KiB |
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 486 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 337 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 370 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 537 KiB After Width: | Height: | Size: 532 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 777 KiB After Width: | Height: | Size: 796 KiB |
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 817 KiB |
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 780 KiB After Width: | Height: | Size: 921 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 3.1 MiB After Width: | Height: | Size: 3.1 MiB |
@@ -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 |
@@ -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="继续"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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: '批判求真',
|
||||
},
|
||||
|
||||
@@ -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: '群众路线',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '坚韧不拔',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -5,11 +5,20 @@
|
||||
|
||||
// 敏感词库(可根据实际需求扩展)
|
||||
const SENSITIVE_WORDS = [
|
||||
'敏感词1',
|
||||
'敏感词2',
|
||||
'测试',
|
||||
'垃圾',
|
||||
// 可以根据实际需求添加更多敏感词
|
||||
'脏话',
|
||||
'骂人',
|
||||
'暴力',
|
||||
'恐怖',
|
||||
'色情',
|
||||
'赌博',
|
||||
'诈骗',
|
||||
'毒品',
|
||||
'邪教',
|
||||
'反动',
|
||||
'辱骂',
|
||||
'仇恨',
|
||||
'sb',
|
||||
]
|
||||
|
||||
type FileSaveHandle = {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -345,7 +345,7 @@ const HuangyanpeiPlace3 = () => {
|
||||
speaker="student"
|
||||
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_07.mp3')}
|
||||
>
|
||||
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在学习的课程有专业课、实习实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
|
||||
这所川沙小学堂就是黄炎培先生“教育救国”的第一个试验田吧?我们现在的专业课、实训课都提倡技能培养,当年他是不是也很注重动手能力呀?
|
||||
</GuideStepsItem>
|
||||
<GuideStepsItem
|
||||
speaker="guide"
|
||||
|
||||
@@ -342,7 +342,7 @@ const HuangyanpeiPlace4 = () => {
|
||||
{/* 题目 */}
|
||||
<SingleChoiceQuizPanel
|
||||
visible={showQuiz}
|
||||
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
|
||||
title={`【单选】中华职业学校首批招生78人,凭借____口号和“免学费、包分配”的优待,开创了中国近代职业教育的先河。`}
|
||||
options={[
|
||||
{ value: 'A', label: 'A. 学而优则仕' },
|
||||
{ value: 'B', label: 'B. 双手万能、手脑并用' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||