chore: 更新素材资源和组件优化

- 更新陈云、鲁迅、聂耳等故居的展品图片素材
- 更新拼图细节图片
- 优化学习地图底图和详情图
- 更新音频素材
- 优化各故居 store 和页面组件
This commit is contained in:
zhongyidian committed 2026-08-12 11:35:09 +08:00
1 parent 165374b986
commit d445f81e60
159 files changed
+4191 -607

No files matched your search

+56
View File
@@ -8,6 +8,9 @@
"name": "shanghai-residences",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
@@ -339,6 +342,59 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 665 KiB

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 3.1 MiB

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: 328 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 692 KiB

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 510 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 KiB

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 KiB

After

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB

After

Width:  |  Height:  |  Size: 795 KiB

@@ -62,7 +62,7 @@ const ContinueLearningModal = ({
</div>
{/* 正文 */}
<p className="mt-10 max-w-[720px] text-center text-4xl font-medium leading-relaxed tracking-wide text-[#5c3a1e]">
<p className="indent-18 mt-10 max-w-[720px] text-4xl font-medium leading-relaxed tracking-wide text-[#5c3a1e]">
{message}
</p>
+3 -1
View File
@@ -42,7 +42,9 @@ const ErrorAlert = ({
{/* 标题和副标题容器 - 带深色背景条 */}
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
{/* 副标题 */}
<div className="text-white text-5xl font-medium tracking-widest leading-18">{text}</div>
<div className="text-white text-5xl font-medium tracking-widest leading-18 indent-26">
{text}
</div>
</div>
{/* 继续按钮 */}
@@ -136,7 +136,7 @@ const BasketQuizPanel = ({
renderOption={(option, isUsed) => (
<div
className={cn(
'relative min-h-[90px] px-10 flex items-center justify-center',
'relative min-h-[90px] px-10 flex items-center',
'rounded-[32px] transition-all duration-300',
'text-[28px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
isUsed || filledValues.has(option.value)
@@ -147,7 +147,7 @@ const BasketQuizPanel = ({
{!(isUsed || filledValues.has(option.value)) && (
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
)}
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] text-center leading-snug">
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] leading-snug">
{option.label}
</span>
</div>
@@ -155,7 +155,7 @@ const BasketQuizPanel = ({
renderDragging={(option) => (
<div
className={cn(
'relative min-h-[60px] px-10 flex items-center justify-center',
'relative min-h-[60px] px-10 flex items-center',
'rounded-[32px]',
'text-[20px] font-bold border-3 shadow-[0_16px_48px_rgba(240,190,117,0.6)]',
'border-[#f0be75] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3]',
@@ -163,7 +163,7 @@ const BasketQuizPanel = ({
)}
>
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] text-center leading-snug whitespace-nowrap">
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] leading-snug whitespace-nowrap">
{option.label}
</span>
</div>
+40 -18
View File
@@ -16,8 +16,15 @@ const DragQuizPanel = ({
blankSeparators,
value,
error,
feedbackType = 'error',
onChange,
onSubmit,
submitText = '提交',
disabled = false,
}: DragQuizPanelProps) => {
const isComplete = blanks.every((blank) =>
value.some((result) => result.blankId === blank.id && result.optionValue !== '')
)
// 跟踪之前的 visible 状态
const prevVisibleRef = useRef(visible)
@@ -62,7 +69,7 @@ const DragQuizPanel = ({
{filledValue ? (
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
<span className="text-[28px]">{filledValue}</span>
<button
{/* <button
type="button"
className="w-7 h-7 flex items-center justify-center rounded-full bg-[#8b4513]/80 hover:bg-[#a0522d] text-white text-sm transition-all hover:scale-110 shadow-lg"
onClick={(e) => {
@@ -71,7 +78,7 @@ const DragQuizPanel = ({
}}
>
×
</button>
</button> */}
</span>
) : (
<span className="text-white/40 text-[24px] font-light">____</span>
@@ -87,48 +94,63 @@ const DragQuizPanel = ({
})}
{questionSuffix && <span>{questionSuffix}</span>}
{/* 答案反馈 */}
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
{error && (
<div
className={cn(
'mt-5 text-center text-[26px]',
feedbackType === 'success' ? 'text-[#b7f7d1]' : 'text-[#fcbe53]'
)}
>
{error}
</div>
)}
</div>
</div>
</div>
{/* 使用 DragFill 组件 */}
<div className="w-[1500px] mt-16">
{/* 选项区域 */}
<div className="w-full max-w-[1300px] mt-10">
<DragFill
options={options}
blanks={blanks}
value={value}
onChange={onChange}
disabled={disabled}
hideLabels
showSubmitButton={false}
layout="vertical"
className="w-full"
className="w-full h-auto!"
optionClassName="grid grid-cols-2 gap-5"
renderOption={(option, isUsed) => (
<div
className={cn(
'relative h-[110px] px-10 flex items-center justify-center',
'rounded-[32px] transition-all duration-300',
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
'px-10 py-4 flex items-center justify-center',
'rounded-2xl transition-all duration-300',
'text-[30px] font-bold border-2',
'shadow-[0_4px_16px_rgba(0,0,0,0.25)]',
isUsed
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
: 'border-[#d4c5a9] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]'
: 'border-[#d4c5a9] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_8px_28px_rgba(240,190,117,0.6)] hover:scale-[1.02]'
)}
>
{!isUsed && (
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
)}
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
{option.label}
</span>
<span className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">{option.label}</span>
</div>
)}
renderBlank={(_blank) => {
// 返回一个隐藏元素,实际的空白区域在题干中
// 但需要通过 data-blank-id 让 DragFill 能找到它
return <div className="hidden" />
}}
/>
</div>
{/* 提交按钮 */}
<button
type="button"
disabled={disabled || !isComplete}
onClick={() => onSubmit?.(value)}
className="mt-8 px-24 py-3 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
>
{submitText}
</button>
</div>
</div>
)
@@ -0,0 +1,208 @@
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import SuccessAlert from '@/components/layout/alert/success-alert'
import type { ErrorCorrectionQuizPanelProps } from './types'
/**
* ErrorCorrectionQuizPanel - 纠错题面板
* 横向展示多个文本区块,用户点击找出其中的错误
*/
const ErrorCorrectionQuizPanel = ({
visible,
zIndex = 10000,
title,
introText,
blocks,
errorCount,
onSubmit,
disabled = false,
}: ErrorCorrectionQuizPanelProps) => {
// 记录用户点击过的区块
const [clickedBlocks, setClickedBlocks] = useState<Set<number>>(new Set())
// 记录找到的错误数量
const [foundErrors, setFoundErrors] = useState(0)
// 当前显示在题干区域的反馈
const [feedbackBlockId, setFeedbackBlockId] = useState<number | null>(null)
// 临时高亮的错误选项
const [wrongFlashBlockId, setWrongFlashBlockId] = useState<number | null>(null)
const wrongFlashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 是否已完成
const [isCompleted, setIsCompleted] = useState(false)
// 重置状态
useEffect(() => {
if (visible) {
if (wrongFlashTimerRef.current) {
clearTimeout(wrongFlashTimerRef.current)
wrongFlashTimerRef.current = null
}
setClickedBlocks(new Set())
setFoundErrors(0)
setFeedbackBlockId(null)
setWrongFlashBlockId(null)
setIsCompleted(false)
}
return () => {
if (wrongFlashTimerRef.current) {
clearTimeout(wrongFlashTimerRef.current)
wrongFlashTimerRef.current = null
}
}
}, [visible])
// 处理区块点击
const handleBlockClick = (blockId: number) => {
if (disabled || isCompleted || clickedBlocks.has(blockId)) return
const block = blocks.find((b) => b.id === blockId)
if (!block) return
const newClickedBlocks = new Set(clickedBlocks)
setFeedbackBlockId(blockId)
if (wrongFlashTimerRef.current) {
clearTimeout(wrongFlashTimerRef.current)
wrongFlashTimerRef.current = null
}
setWrongFlashBlockId(null)
// 如果点击的是错误区块,增加找到的错误数
if (block.isError) {
newClickedBlocks.add(blockId)
setClickedBlocks(newClickedBlocks)
const newFoundErrors = foundErrors + 1
setFoundErrors(newFoundErrors)
// 检查是否找全了所有错误
if (newFoundErrors === errorCount) {
setIsCompleted(true)
}
} else {
setWrongFlashBlockId(blockId)
wrongFlashTimerRef.current = setTimeout(() => {
setWrongFlashBlockId(null)
wrongFlashTimerRef.current = null
}, 500)
}
}
// 获取区块的显示状态
const getBlockStatus = (blockId: number) => {
if (!clickedBlocks.has(blockId)) return 'unclicked'
const block = blocks.find((b) => b.id === blockId)
if (!block) return 'unclicked'
return block.isError ? 'found-error' : 'wrong-selection'
}
if (!visible) return null
const feedbackBlock = blocks.find((block) => block.id === feedbackBlockId)
const feedbackStatus = feedbackBlock?.isError ? 'found-error' : 'wrong-selection'
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/45" style={{ zIndex }}>
<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-[#130f0b]/95 px-20 py-9 shadow-[0_20px_60px_rgba(240,190,117,0.75)] border-4 border-[#d4c5a9] mb-8">
<div className="flex items-start gap-6">
{/* <div className="shrink-0 mt-1 rounded-full border-2 border-[#fcbe53] bg-[#fcbe53]/15 px-5 py-2 text-[24px] font-bold text-[#fcbe53]">
纠错
</div> */}
<div className="flex-1">
<div className="text-[34px] leading-relaxed text-white">
{title}
<span className="ml-4 inline-flex rounded-full border border-[#fcbe53]/60 bg-[#fcbe53]/15 px-4 py-1 text-[24px] text-[#fcbe53] align-baseline">
已找到 {foundErrors} / {errorCount} 处错误
</span>
</div>
{introText && (
<div className="indent-13 mt-4 text-[26px] leading-relaxed text-white/75">
{introText}
</div>
)}
</div>
</div>
{feedbackBlock && (
<div
className={cn(
'mt-6 rounded-2xl border-2 px-8 py-4 text-center text-[26px] leading-relaxed',
feedbackStatus === 'found-error'
? 'border-[#fcbe53]/70 bg-[#fcbe53]/15 text-[#ffe3a8]'
: 'border-white/20 bg-white/10 text-white/85'
)}
>
{/* <span className="mr-3 font-bold">
{feedbackStatus === 'found-error' ? '找到疑点:' : '不是疑点:'}
</span> */}
{feedbackBlock.feedback}
</div>
)}
</div>
{/* 文本区块(横向排列) */}
<div className="w-full max-w-[1700px] grid grid-cols-5 gap-4">
{blocks.map((block) => {
const status = getBlockStatus(block.id)
const isClicked = clickedBlocks.has(block.id)
const isWrongFlashing = wrongFlashBlockId === block.id
return (
<div key={block.id} className="flex flex-col gap-3">
{/* 区块本身 */}
<div
className={cn(
'relative px-6 py-6 rounded-[26px] border-4 transition-all duration-300',
'text-[24px] leading-relaxed cursor-pointer select-none',
'min-h-[210px] flex items-center justify-center',
'shadow-[0_10px_26px_rgba(43,28,16,0.25)]',
!isClicked &&
!isWrongFlashing &&
'border-[#d4c5a9] bg-[#f5f0e6] text-[#5c3e2e] hover:border-[#fcbe53] hover:-translate-y-1 hover:shadow-[0_16px_32px_rgba(240,190,117,0.35)]',
isWrongFlashing &&
'border-white/70 bg-[#2c2925]/90 text-white shadow-[0_0_24px_rgba(255,255,255,0.2)]',
status === 'found-error' &&
'border-[#fcbe53] bg-linear-to-br from-[#5b321e] to-[#2a1810] text-[#fff3d8] shadow-[0_0_28px_rgba(252,190,83,0.55)]',
status === 'wrong-selection' &&
'border-white/70 bg-[#2c2925]/90 text-white shadow-[0_0_24px_rgba(255,255,255,0.2)]',
(disabled || isCompleted) && !isClicked && 'opacity-50 cursor-not-allowed'
)}
onClick={() => handleBlockClick(block.id)}
>
{isWrongFlashing && (
<div className="absolute -top-4 right-4 rounded-full border-2 border-white/60 bg-[#2c2925] px-4 py-2 text-[20px] font-bold text-white shadow-lg">
再看看
</div>
)}
{status === 'found-error' && (
<div className="absolute -top-4 right-4 rounded-full border-2 border-[#ffe3a8] bg-[#fcbe53] px-4 py-2 text-[20px] font-bold text-[#5c3218] shadow-lg">
已标记
</div>
)}
{status === 'wrong-selection' && (
<div className="absolute -top-4 right-4 rounded-full border-2 border-white/60 bg-[#2c2925] px-4 py-2 text-[20px] font-bold text-white shadow-lg">
再看看
</div>
)}
<div className="indent-12">{block.text}</div>
</div>
</div>
)
})}
</div>
{/* 完成提示 */}
<SuccessAlert
visible={isCompleted}
title="恭喜你,找出了所有错误!"
onClose={() => onSubmit?.(true)}
/>
</div>
</div>
)
}
export default ErrorCorrectionQuizPanel
@@ -0,0 +1,276 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import type { SortQuizPanelProps } from './types'
/**
* SortQuizPanel - 排序题面板
* 支持拖拽排序,用于让用户按正确顺序排列选项
*/
const SortQuizPanel = ({
visible,
zIndex = 10000,
title,
items,
correctOrder,
errorMessage,
successMessage,
onSubmit,
onError,
disabled = false,
submitText = '提交',
}: SortQuizPanelProps) => {
// 当前排序状态
const [currentOrder, setCurrentOrder] = useState<string[]>([])
const [error, setError] = useState<string>('')
const [isSubmitted, setIsSubmitted] = useState(false)
// 拖拽状态
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null)
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const panelRef = useRef<HTMLDivElement | null>(null)
// 初始化排序(使用items的初始顺序)
useEffect(() => {
if (visible && items.length > 0) {
setCurrentOrder(items.map((item) => item.id))
setError('')
setIsSubmitted(false)
}
}, [visible, items])
const getLocalPoint = useCallback((clientX: number, clientY: number) => {
const panelElement = panelRef.current
if (!panelElement) return { x: clientX, y: clientY }
const rect = panelElement.getBoundingClientRect()
const scaleX = rect.width / panelElement.offsetWidth || 1
const scaleY = rect.height / panelElement.offsetHeight || 1
return {
x: (clientX - rect.left) / scaleX,
y: (clientY - rect.top) / scaleY,
}
}, [])
const getLocalRect = useCallback((element: HTMLElement) => {
const panelElement = panelRef.current
if (!panelElement) return element.getBoundingClientRect()
const panelRect = panelElement.getBoundingClientRect()
const rect = element.getBoundingClientRect()
const scaleX = panelRect.width / panelElement.offsetWidth || 1
const scaleY = panelRect.height / panelElement.offsetHeight || 1
return {
left: (rect.left - panelRect.left) / scaleX,
top: (rect.top - panelRect.top) / scaleY,
width: rect.width / scaleX,
height: rect.height / scaleY,
right: (rect.right - panelRect.left) / scaleX,
bottom: (rect.bottom - panelRect.top) / scaleY,
}
}, [])
const updateDragOverIndex = useCallback((clientX: number, clientY: number) => {
for (let i = 0; i < itemRefs.current.length; i++) {
const element = itemRefs.current[i]
if (!element) continue
const rect = element.getBoundingClientRect()
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
setDragOverIndex(i)
return
}
}
setDragOverIndex(null)
}, [])
// 处理拖拽开始(Pointer Events 同时兼容鼠标、触摸和手写笔)
const handlePointerDown = (e: React.PointerEvent, index: number) => {
if (disabled || isSubmitted || e.button !== 0) return
const itemElement = itemRefs.current[index]
if (!itemElement) return
const localPoint = getLocalPoint(e.clientX, e.clientY)
const localRect = getLocalRect(itemElement)
dragOffsetRef.current = {
x: localPoint.x - localRect.left,
y: localPoint.y - localRect.top,
}
setDraggingIndex(index)
setDragPosition(localPoint)
updateDragOverIndex(e.clientX, e.clientY)
e.currentTarget.setPointerCapture(e.pointerId)
e.preventDefault()
}
// 处理放下
const handleDrop = useCallback(() => {
if (draggingIndex === null) return
if (dragOverIndex !== null && dragOverIndex !== draggingIndex) {
const newOrder = [...currentOrder]
const removed = newOrder[draggingIndex]
if (removed === undefined) return
newOrder.splice(draggingIndex, 1)
newOrder.splice(dragOverIndex, 0, removed)
setCurrentOrder(newOrder)
}
setDraggingIndex(null)
setDragOverIndex(null)
setDragPosition(null)
}, [currentOrder, dragOverIndex, draggingIndex])
// 处理拖拽移动
useEffect(() => {
if (draggingIndex === null) return
const handlePointerMove = (e: PointerEvent) => {
setDragPosition(getLocalPoint(e.clientX, e.clientY))
updateDragOverIndex(e.clientX, e.clientY)
e.preventDefault()
}
const handlePointerUp = () => {
handleDrop()
}
document.addEventListener('pointermove', handlePointerMove, { passive: false })
document.addEventListener('pointerup', handlePointerUp)
document.addEventListener('pointercancel', handlePointerUp)
return () => {
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
document.removeEventListener('pointercancel', handlePointerUp)
}
}, [draggingIndex, getLocalPoint, handleDrop, updateDragOverIndex])
// 提交验证
const handleSubmit = () => {
if (disabled || isSubmitted) return
const isCorrect = JSON.stringify(currentOrder) === JSON.stringify(correctOrder)
if (isCorrect) {
setError('')
setIsSubmitted(true)
onSubmit?.(currentOrder, true)
} else {
setError(errorMessage || '排序不正确,请重新调整顺序')
onError?.(errorMessage || '排序不正确,请重新调整顺序')
onSubmit?.(currentOrder, false)
}
}
if (!visible) return null
return (
<div
ref={panelRef}
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
>
<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-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
{isSubmitted && successMessage && (
<div className="mt-5 text-center text-[26px] text-[#4ade80]">{successMessage}</div>
)}
</div>
{/* 排序选项区域 */}
<div className="w-full max-w-[1500px] flex flex-col gap-4 mb-8">
{currentOrder.map((itemId, index) => {
const item = items.find((i) => i.id === itemId)
if (!item) return null
const isDragging = draggingIndex === index
const isDragOver = dragOverIndex === index && dragOverIndex !== draggingIndex
return (
<div
key={itemId}
ref={(el) => {
itemRefs.current[index] = el
}}
className={cn(
'relative px-8 py-6 rounded-2xl border-3 transition-all duration-200 touch-none',
'text-[28px] leading-relaxed select-none',
disabled || isSubmitted
? 'cursor-not-allowed opacity-60'
: 'cursor-grab active:cursor-grabbing',
isDragging
? 'opacity-50 scale-95'
: isDragOver
? 'border-[#f0be75] bg-[#f0be75]/20 scale-105'
: 'border-[#d4c5a9] bg-[#d7b888] text-[#5c3e2e] hover:border-[#f0be75] hover:shadow-lg'
)}
onPointerDown={(e) => handlePointerDown(e, index)}
>
<div className="flex items-center gap-4">
{/* 拖拽手柄图标 */}
<div className="flex flex-col gap-1 opacity-50">
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
</div>
<div className="flex-1">{item.label}</div>
</div>
</div>
)
})}
</div>
{/* 提交按钮 */}
{!isSubmitted && (
<button
type="button"
onClick={handleSubmit}
disabled={disabled}
className={cn(
'px-16 py-4 rounded-2xl text-[32px] font-bold transition-all duration-300',
'shadow-[0_8px_24px_rgba(0,0,0,0.3)]',
disabled
? 'bg-gray-400 text-gray-600 cursor-not-allowed'
: 'bg-[#f0be75] text-[#5c3e2e] hover:bg-[#e0ae65] hover:scale-105 hover:shadow-[0_12px_32px_rgba(240,190,117,0.6)]'
)}
>
{submitText}
</button>
)}
{/* 拖拽预览 */}
{draggingIndex !== null && dragPosition && (
<div
className="fixed w-full max-w-[1500px] pointer-events-none z-[10001]"
style={{
left: `${dragPosition.x - dragOffsetRef.current.x}px`,
top: `${dragPosition.y - dragOffsetRef.current.y}px`,
}}
>
<div className="px-8 py-6 rounded-2xl border-3 border-[#f0be75] bg-[#d7b888] text-[#5c3e2e] text-[28px] shadow-2xl opacity-90">
{items.find((i) => i.id === currentOrder[draggingIndex])?.label}
</div>
</div>
)}
</div>
</div>
)
}
export default SortQuizPanel
+6
View File
@@ -5,6 +5,8 @@ export { default as DragQuizPanel } from './DragQuizPanel'
export { default as BasketQuizPanel } from './BasketQuizPanel'
export { default as MatchQuizPanel } from './MatchQuizPanel'
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
export { default as SortQuizPanel } from './SortQuizPanel'
export { default as ErrorCorrectionQuizPanel } from './ErrorCorrectionQuizPanel'
export type {
SingleChoiceQuizPanelProps,
MultipleChoiceQuizPanelProps,
@@ -15,6 +17,10 @@ export type {
SelectFillQuizPanelProps,
SelectFillBlank,
SelectFillResult,
SortQuizPanelProps,
SortQuizItem,
ErrorCorrectionQuizPanelProps,
ErrorCorrectionBlock,
} from './types'
// 保留旧组件以保持向后兼容
+72
View File
@@ -37,6 +37,12 @@ export interface DragQuizPanelProps {
feedbackType?: 'success' | 'error'
/** 值变化回调 */
onChange: (value: DragFillResult[]) => void
/** 所有空白填满后点击提交的回调 */
onSubmit?: (value: DragFillResult[]) => void
/** 提交按钮文本 */
submitText?: string
/** 是否禁用拖拽和提交 */
disabled?: boolean
}
/** 单选题面板配置 */
@@ -192,6 +198,72 @@ export interface SelectFillQuizPanelProps {
submitText?: string
}
/** 排序题项 */
export interface SortQuizItem {
/** 唯一标识 */
id: string
/** 显示文本 */
label: string
}
/** 排序题面板配置 */
export interface SortQuizPanelProps {
/** 是否可见 */
visible: boolean
/** z-index 层级 */
zIndex?: number
/** 题目 */
title: string
/** 待排序的选项列表(初始顺序) */
items: SortQuizItem[]
/** 正确的排序顺序(id数组) */
correctOrder: string[]
/** 错误提示信息 */
errorMessage?: string
/** 成功提示信息(可选) */
successMessage?: string
/** 提交回调,返回用户排序和是否正确 */
onSubmit?: (order: string[], isCorrect: boolean) => void
/** 错误回调(可选) */
onError?: (error: string) => void
/** 是否禁用 */
disabled?: boolean
/** 提交按钮文本 */
submitText?: string
}
/** 纠错题区块 */
export interface ErrorCorrectionBlock {
/** 区块ID */
id: number
/** 区块文本内容 */
text: string
/** 是否是错误 */
isError: boolean
/** 点击后的反馈文本 */
feedback: string
}
/** 纠错题面板配置 */
export interface ErrorCorrectionQuizPanelProps {
/** 是否可见 */
visible: boolean
/** z-index 层级 */
zIndex?: number
/** 题目 */
title: string
/** 情景介绍文本(可选) */
introText?: string
/** 文本区块列表 */
blocks: ErrorCorrectionBlock[]
/** 需要找出的错误数量 */
errorCount: number
/** 提交回调(找到所有错误时自动调用) */
onSubmit?: (isCorrect: boolean) => void
/** 是否禁用 */
disabled?: boolean
}
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
export interface QuizPanelProps {
visible: boolean
+6 -10
View File
@@ -237,7 +237,7 @@ const LetterTemplate = ({
} else if (/\d/.test(value)) {
showHint('不能包含数字')
} else if (/[\p{P}\p{S}]/u.test(value)) {
showHint('不能包含标点符号')
showHint('不能包含特殊符号')
}
}
@@ -331,7 +331,7 @@ const LetterTemplate = ({
} else if (/\d/.test(value)) {
showHint('不能包含数字')
} else if (/[\p{P}\p{S}]/u.test(value)) {
showHint('不能包含标点符号')
showHint('不能包含特殊符号')
}
}
@@ -549,14 +549,10 @@ const LetterTemplate = ({
/>
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:0px_0px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921]">
<div
className="text-white text-6xl font-bold tracking-[20px]"
style={{
writingMode: 'vertical-rl',
textOrientation: 'upright',
}}
>
{title}
<div className="flex flex-col items-center text-white text-6xl font-bold leading-none gap-[20px]">
{Array.from(title).map((char, index) => (
<span key={`title-${index}`}>{char}</span>
))}
</div>
</div>
+8 -3
View File
@@ -47,16 +47,21 @@ const fields: Record<number, LetterFieldConfig> = {
3: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
widthClassName: 'w-[120px]',
readOnlyFallback: '专业',
},
4: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
widthClassName: 'w-[180px]',
placeholder: '结合所学专业',
readOnlyFallback: '实干报国',
},
5: {
type: 'input',
maxLength: 12,
widthClassName: 'w-[120px]',
},
}
const LetterToChenyun = ({
+44 -1
View File
@@ -575,8 +575,14 @@ const Video = forwardRef<VideoRef, VideoProps>(
// 计算进度百分比
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
const disableFirstWatchVideoGating =
// 视频首次观看限制:env 默认值可被调试面板运行时覆盖
const envDisableGating =
import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
const disableFirstWatchVideoGating =
sessionOverride !== null ? sessionOverride === 'disabled' : envDisableGating
const canShowSkipButton = !skipKey || disableFirstWatchVideoGating || isVideoCompleted
const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted
const shouldShowReadonlyProgress =
@@ -642,6 +648,43 @@ const Video = forwardRef<VideoRef, VideoProps>(
style={{ transform: `scaleX(${progressPercent / 100})` }}
/>
</div>
{/* 锚点标记:纵向竖标 + 圆点 */}
{visibleAnchors.map((anchor) => {
const anchorPercent = duration > 0 ? (anchor.time / duration) * 100 : 0
const isPassed = currentTime >= anchor.time
return (
<div
key={anchor.id}
className="absolute bottom-0 flex flex-col items-center"
style={{
left: `${anchorPercent}%`,
transform: 'translateX(-50%)',
}}
title={`题目: ${formatTime(anchor.time)}`}
>
{/* 竖线 */}
<div
className="w-px"
style={{
height: '14px',
background: isPassed
? 'linear-gradient(to top, #fbbf24, transparent)'
: 'linear-gradient(to top, rgba(255,255,255,0.7), transparent)',
}}
/>
{/* 圆点 */}
<div
className="w-2 h-2 rounded-full"
style={{
background: isPassed ? '#fbbf24' : '#ffffff',
boxShadow: isPassed
? '0 0 8px rgba(251,191,36,0.8)'
: '0 0 5px rgba(255,255,255,0.6)',
}}
/>
</div>
)
})}
</div>
)}
{/* 跳过按钮 */}
+5 -5
View File
@@ -357,7 +357,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
13: {
title: '冯雪峰与鲁迅谈话的资料图',
title: '鲁迅全家与冯雪峰全家合影',
detailImage: 'luxun/details/13',
route: 'place4',
},
@@ -419,7 +419,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
9: {
title: '中共四大立碑处',
title: '中共四大遗址立碑处',
detailImage: 'maozedong/details/9',
route: 'place4',
},
@@ -476,7 +476,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place1',
},
4: {
title: '英雄儿女胶片',
title: '风云儿女胶片',
detailImage: 'nieer/details/4',
route: 'place2',
},
@@ -486,7 +486,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2',
},
6: {
title: '聂耳手写《义勇军进行曲》',
title: '《义勇军进行曲》\n曲谱的手稿',
detailImage: 'nieer/details/6',
route: 'place3',
},
@@ -526,7 +526,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place6',
},
14: {
title: '唱国歌一定要站起来',
title: '定格阅兵瞬间',
detailImage: 'nieer/details/14',
route: 'reflection',
},
+303 -7
View File
@@ -1,6 +1,7 @@
import { puzzleDataConfig } from '@/config/puzzleData'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
import { savePuzzleStore } from '@/store/puzzleStore'
const MODE_KEY = 'debug:progression-mode'
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
@@ -20,6 +21,21 @@ type BadgePosition = {
offset: number
}
/** 故居碎片按 route 前缀分三阶段 */
type StageKey = 'dialogue' | 'reflection' | 'internalization'
interface StageConfig {
key: StageKey
label: string
routePrefix: string
}
const STAGES: StageConfig[] = [
{ key: 'dialogue', label: '对话', routePrefix: 'place' },
{ key: 'reflection', label: '体悟', routePrefix: 'reflection' },
{ key: 'internalization', label: '内化', routePrefix: 'internalization' },
]
const serializePuzzles = (puzzles: Record<string, Set<number>>) => {
return Object.fromEntries(
Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]])
@@ -62,6 +78,91 @@ const buildAllCompletedPuzzles = () => {
return puzzles
}
/**
* 获取指定故居各阶段的碎片 ID 集合
*/
const getStagePieceIds = (residence: string) => {
const residencePieces = puzzleDataConfig[residence]
const result: Record<StageKey, number[]> = {
dialogue: [],
reflection: [],
internalization: [],
}
if (!residencePieces) return result
for (const [pieceId, data] of Object.entries(residencePieces)) {
for (const stage of STAGES) {
if (data.route.startsWith(stage.routePrefix)) {
result[stage.key].push(Number(pieceId))
break
}
}
}
return result
}
/**
* 获取某个故居在某阶段已收集的碎片数 / 总数
*/
const getStageProgress = (residence: string, stageKey: StageKey): { collected: number; total: number } => {
const stagePieceIds = getStagePieceIds(residence)[stageKey]
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
const collected = stagePieceIds.filter((id) => currentPieces.has(id)).length
return { collected, total: stagePieceIds.length }
}
/**
* 确保目标故居的前序故居碎片完整(保证解锁关系),并合并当前已有碎片。
*/
const buildPuzzlesWithAncestors = (residence: string): Record<string, Set<number>> => {
const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
const puzzles: Record<string, Set<number>> = {}
for (let i = 0; i < targetIndex; i++) {
const prev = PUZZLE_RESIDENCES[i]?.residence
if (!prev) continue
puzzles[prev] = new Set(getAllPieceIds(prev))
}
const currentPuzzles = usePuzzleStore.getState().puzzles
for (const [res, pieces] of Object.entries(currentPuzzles)) {
if (res === residence) continue
if (!puzzles[res]) {
puzzles[res] = pieces
} else {
puzzles[res] = new Set([...puzzles[res], ...pieces])
}
}
return puzzles
}
/**
* 直接切换一个故居某个阶段的碎片收集状态(无确认、无刷新)
*/
const toggleResidenceStage = (residence: string, stageKey: StageKey) => {
const stagePieceIds = getStagePieceIds(residence)[stageKey]
if (stagePieceIds.length === 0) return
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
const allCollected = stagePieceIds.every((id) => currentPieces.has(id))
const puzzles = buildPuzzlesWithAncestors(residence)
if (allCollected) {
const remaining = new Set<number>()
for (const id of currentPieces) {
if (!stagePieceIds.includes(id)) {
remaining.add(id)
}
}
puzzles[residence] = remaining
} else {
puzzles[residence] = new Set([...currentPieces, ...stagePieceIds])
}
usePuzzleStore.setState({ puzzles })
savePuzzleStore(puzzles)
}
const getStoredMode = (): DebugMode => {
const raw = window.sessionStorage.getItem(MODE_KEY)
if (!raw) return { type: 'real' }
@@ -91,6 +192,10 @@ const getModeLabel = (mode = getStoredMode()) => {
return '真实进度'
}
const getResidenceLabel = (residence: string) => {
return PUZZLE_RESIDENCES.find((r) => r.residence === residence)?.displayName ?? residence
}
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
const getStoredBadgePosition = (): BadgePosition => {
@@ -224,6 +329,63 @@ const removePanel = () => {
document.getElementById(PANEL_ID)?.remove()
}
// ========== 面板渲染 ==========
const renderStageButton = (
residence: string,
displayName: string,
stage: StageConfig,
): HTMLButtonElement => {
const { collected, total } = getStageProgress(residence, stage.key)
const isFull = total > 0 && collected >= total
const btn = document.createElement('button')
btn.type = 'button'
btn.textContent = `${stage.label} ${collected}/${total}`
btn.style.flex = '1'
btn.style.border = '1px solid'
btn.style.borderRadius = '4px'
btn.style.fontSize = '10px'
btn.style.padding = '3px 4px'
btn.style.cursor = total > 0 ? 'pointer' : 'default'
btn.style.flexShrink = '0'
btn.style.opacity = total > 0 ? '1' : '0.3'
if (total === 0) {
btn.style.borderColor = 'rgba(255,255,255,0.08)'
btn.style.background = 'rgba(255,255,255,0.04)'
btn.style.color = 'rgba(255,255,255,0.3)'
btn.title = `${displayName} 没有「${stage.label}」阶段`
} else if (isFull) {
btn.style.borderColor = 'rgba(100,220,120,0.5)'
btn.style.background = 'rgba(80,200,100,0.18)'
btn.style.color = '#99e8aa'
btn.title = `「${stage.label}」已完成(${total} 块)。点击移除。`
} else if (collected > 0) {
btn.style.borderColor = 'rgba(255,200,80,0.5)'
btn.style.background = 'rgba(255,180,60,0.15)'
btn.style.color = '#ffd080'
btn.title = `「${stage.label}」部分完成(${collected}/${total})。点击补全。`
} else {
btn.style.borderColor = 'rgba(100,180,255,0.4)'
btn.style.background = 'rgba(80,150,255,0.12)'
btn.style.color = '#99ccff'
btn.title = `「${stage.label}」未开始。点击补全(${total} 块)。`
}
if (total > 0) {
btn.addEventListener('click', (e) => {
e.stopPropagation()
toggleResidenceStage(residence, stage.key)
// 即时刷新面板按钮状态
removePanel()
togglePanel()
})
}
return btn
}
const togglePanel = () => {
const existingPanel = document.getElementById(PANEL_ID)
if (existingPanel) {
@@ -237,7 +399,7 @@ const togglePanel = () => {
panel.style.right = '12px'
panel.style.top = '72px'
panel.style.zIndex = '99999'
panel.style.width = '260px'
panel.style.width = '320px'
panel.style.maxHeight = '70vh'
panel.style.overflowY = 'auto'
panel.style.padding = '10px'
@@ -254,11 +416,86 @@ const togglePanel = () => {
panel.appendChild(title)
const description = document.createElement('div')
description.textContent = '只写本地 puzzleStore,不调用易班保存。'
description.textContent = '只写本地 store,不调用易班保存。点击阶段按钮即时生效。'
description.style.opacity = '0.75'
description.style.marginBottom = '8px'
description.style.marginBottom = '6px'
panel.appendChild(description)
// ===== 视频首次观看限制开关 =====
const gatingSection = document.createElement('div')
gatingSection.style.display = 'flex'
gatingSection.style.alignItems = 'center'
gatingSection.style.gap = '6px'
gatingSection.style.marginBottom = '8px'
gatingSection.style.padding = '6px 8px'
gatingSection.style.borderRadius = '6px'
gatingSection.style.background = 'rgba(255,255,255,0.06)'
const gatingLabel = document.createElement('span')
gatingLabel.textContent = '视频首次限制:'
gatingLabel.style.fontSize = '11px'
gatingLabel.style.opacity = '0.8'
gatingSection.appendChild(gatingLabel)
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
const envDisableGating = import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
// sessionOverride: null=跟随env, 'enabled'=强制首次(无进度条/跳过), 'disabled'=强制关闭(有进度条/跳过)
const isGatingOn = sessionOverride !== null
? sessionOverride === 'enabled'
: !envDisableGating
const gatingBtn = document.createElement('button')
gatingBtn.type = 'button'
gatingBtn.textContent = isGatingOn ? '开启(无进度条)' : '关闭(有进度条)'
gatingBtn.title = isGatingOn
? '当前:模拟首次学习体验(视频不可跳过、无进度条)。点击切换。'
: '当前:跳过首次学习限制(视频可跳过、有进度条)。点击切换。'
gatingBtn.style.border = isGatingOn
? '1px solid rgba(255,200,80,0.5)'
: '1px solid rgba(100,200,120,0.5)'
gatingBtn.style.borderRadius = '4px'
gatingBtn.style.background = isGatingOn
? 'rgba(255,180,60,0.2)'
: 'rgba(80,200,100,0.18)'
gatingBtn.style.color = isGatingOn ? '#ffd080' : '#99e8aa'
gatingBtn.style.fontSize = '11px'
gatingBtn.style.padding = '4px 8px'
gatingBtn.style.cursor = 'pointer'
gatingBtn.style.flex = '1'
gatingBtn.addEventListener('click', (e) => {
e.stopPropagation()
if (isGatingOn) {
window.sessionStorage.setItem('debug:video-gating', 'disabled')
} else {
window.sessionStorage.setItem('debug:video-gating', 'enabled')
}
removePanel()
togglePanel()
})
gatingSection.appendChild(gatingBtn)
const resetGatingBtn = document.createElement('button')
resetGatingBtn.type = 'button'
resetGatingBtn.textContent = '重置'
resetGatingBtn.title = `恢复跟随环境变量(VITE_DISABLE_FIRST_WATCH_VIDEO_GATING=${envDisableGating ? 'true' : 'false'})`
resetGatingBtn.style.border = '1px solid rgba(255,255,255,0.2)'
resetGatingBtn.style.borderRadius = '4px'
resetGatingBtn.style.background = 'rgba(255,255,255,0.08)'
resetGatingBtn.style.color = '#aaa'
resetGatingBtn.style.fontSize = '10px'
resetGatingBtn.style.padding = '4px 6px'
resetGatingBtn.style.cursor = 'pointer'
resetGatingBtn.addEventListener('click', (e) => {
e.stopPropagation()
window.sessionStorage.removeItem('debug:video-gating')
removePanel()
togglePanel()
})
gatingSection.appendChild(resetGatingBtn)
panel.appendChild(gatingSection)
// ===== 全局模式按钮 =====
const list = document.createElement('div')
list.style.display = 'flex'
list.style.flexDirection = 'column'
@@ -266,14 +503,73 @@ const togglePanel = () => {
list.appendChild(createButton('真实进度', () => applyMode({ type: 'real' })))
// ===== 按故居分阶段控制 =====
const residenceSection = document.createElement('div')
residenceSection.style.display = 'flex'
residenceSection.style.flexDirection = 'column'
residenceSection.style.gap = '4px'
const sectionTitle = document.createElement('div')
sectionTitle.textContent = '— 各故居阶段控制 —'
sectionTitle.style.fontSize = '11px'
sectionTitle.style.opacity = '0.6'
sectionTitle.style.marginTop = '4px'
sectionTitle.style.marginBottom = '2px'
residenceSection.appendChild(sectionTitle)
for (const residence of PUZZLE_RESIDENCES) {
list.appendChild(
createButton(`到达:${residence.displayName}`, () =>
// 名称行 + 到达按钮
const row1 = document.createElement('div')
row1.style.display = 'flex'
row1.style.gap = '4px'
row1.style.alignItems = 'center'
const label = document.createElement('span')
label.textContent = residence.displayName
label.style.flex = '1'
label.style.fontSize = '11px'
label.style.fontWeight = 'bold'
label.style.opacity = '0.9'
label.style.overflow = 'hidden'
label.style.textOverflow = 'ellipsis'
label.style.whiteSpace = 'nowrap'
row1.appendChild(label)
const arrivedBtn = document.createElement('button')
arrivedBtn.type = 'button'
arrivedBtn.textContent = '到达'
arrivedBtn.title = '模拟到达该故居(前序全部完成,该故居未开始)'
arrivedBtn.style.border = '1px solid rgba(255,255,255,0.25)'
arrivedBtn.style.borderRadius = '4px'
arrivedBtn.style.background = 'rgba(255,255,255,0.1)'
arrivedBtn.style.color = '#ddd'
arrivedBtn.style.fontSize = '10px'
arrivedBtn.style.padding = '2px 5px'
arrivedBtn.style.cursor = 'pointer'
arrivedBtn.style.flexShrink = '0'
arrivedBtn.addEventListener('click', (e) => {
e.stopPropagation()
applyMode({ type: 'arrived', residence: residence.residence, label: residence.displayName })
)
)
})
row1.appendChild(arrivedBtn)
residenceSection.appendChild(row1)
// 阶段按钮行
const row2 = document.createElement('div')
row2.style.display = 'flex'
row2.style.gap = '4px'
row2.style.marginBottom = '6px'
for (const stage of STAGES) {
row2.appendChild(renderStageButton(residence.residence, residence.displayName, stage))
}
residenceSection.appendChild(row2)
}
list.appendChild(residenceSection)
list.appendChild(createButton('全流程完成', () => applyMode({ type: 'allCompleted' })))
panel.appendChild(list)
+2 -5
View File
@@ -3,11 +3,8 @@ export { usePageScale } from './usePageScale'
export { useTouch } from './useTouch'
export { useZoomAndDrag } from './useZoomAndDrag'
export { useCursorController } from './useCursorController'
export { useResidenceContinue } from './useResidenceContinue'
export { useResidenceContinue, setPreviousPath } from './useResidenceContinue'
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
export type { CursorControllerConfig } from './useCursorController'
export type {
UseResidenceContinueParams,
UseResidenceContinueResult,
} from './useResidenceContinue'
export type { UseResidenceContinueParams, UseResidenceContinueResult } from './useResidenceContinue'
+12 -13
View File
@@ -16,11 +16,16 @@ import { useVideoContextSafe } from '@/contexts/VideoContext'
const promptedResidences = new Set<string>()
/**
* 记录上一次访问的路由,用于判断是否刚从场景离开。
* 模块级变量,跨 hook 实例共享。
* 记录上次 ResidenceLayout 访问的路径,由 ResidenceLayout 在路由变化时更新。
* 用于判断 Home 页面之前是否来自本故居的 place/reflection/internalization/draw 场景。
*/
let previousPath = ''
/** 由 ResidenceLayout 调用,记录每次路由变化的路径 */
export const setPreviousPath = (path: string) => {
previousPath = path
}
export interface UseResidenceContinueParams {
/** 故居标识(同时也是路由里的 starName),如 'maozedong' */
residence: string
@@ -50,7 +55,7 @@ export interface UseResidenceContinueResult {
* 3. 本会话内该故居尚未自动弹过
* 4. 故居已解锁 && 已开始 && 未全部完成
* 5. 能解析出有效的续学目标
* 6. 不是刚从本故居场景页面离开的(避免干扰正常流程)
* 6. 上一页不是本故居的 place/reflection/internalization/draw(避免从场景返回时误弹)
*
* 弹一次后即标记,之后再回到 home 不再自动弹(由首页的三个阶段按钮继续)。
*/
@@ -79,21 +84,15 @@ export function useResidenceContinue({
// 没开始过的新故居不打扰,让学生自己点「一」正常开始
if (!isResidenceStarted(residence, puzzles)) return
// 检查上一个路由:如果是从本故居的 place/reflection/internalization 来的,不弹
// 例如从 /revolutionary/maozedong/place1 回到 /revolutionary/maozedong/home
const currentPath = location.pathname
const isFromResidenceScene =
// 如果上一页是本故居的 place/reflection/internalization/draw,不弹
// 例如从 /revolutionary/maozedong/place4 回到 /revolutionary/maozedong/home
if (
previousPath &&
(previousPath.includes(`/${residence}/place`) ||
previousPath.includes(`/${residence}/reflection`) ||
previousPath.includes(`/${residence}/internalization`) ||
previousPath.includes(`/${residence}/draw`))
// 更新 previousPath 供下次判断
previousPath = currentPath
if (isFromResidenceScene) {
// 刚从场景离开,不弹窗
) {
return
}
+11
View File
@@ -184,6 +184,17 @@ export async function getUserDefineResult(
return getUserDefineResultAsyncInternal(lessonId, key, options)
}
export function parseUserDefineContent<T>(content: string): T {
const raw = JSON.parse(content)
if (useUserStore.getState().yibanConnectionStatus === 'connected') {
return raw as T
}
if (raw && typeof raw === 'object' && 'state' in raw) {
return (raw as { state: T }).state
}
return raw as T
}
// 根据课时顺序号(所处课程的第几课时)更新对应易班平台上的每个环节进度(异步)
export async function setCourseTimeProgress(order: number): Promise<boolean> {
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
File diff suppressed because it is too large. Load diff
+7 -60
View File
@@ -3,60 +3,7 @@
* 用于过滤用户输入中的敏感词汇,将其替换为 *
*/
// 敏感词库(可根据实际需求扩展)
const SENSITIVE_WORDS = [
// 基础不文明用语
'垃圾',
'脏话',
'骂人',
'辱骂',
'仇恨',
'傻逼',
'煞笔',
'傻b',
'傻比',
'sb',
'SB',
'草泥马',
'操',
'妈的',
'他妈',
'你妈',
'去死',
'找死',
'该死',
'白痴',
'智障',
'弱智',
'脑残',
'废物',
'贱',
'滚',
// 暴力相关
'暴力',
'恐怖',
'杀人',
'杀害',
'血腥',
'虐待',
// 违法违规
'色情',
'赌博',
'诈骗',
'毒品',
'邪教',
'反动',
'法轮功',
'传销',
'洗钱',
'贩毒',
'走私',
// 歧视性用语
'歧视',
'种族',
'民族歧视',
]
import { SENSITIVE_WORDS } from './sensitiveWordList'
type FileSaveHandle = {
createWritable: () => Promise<{
write: (data: Blob) => Promise<void>
@@ -132,8 +79,8 @@ export function containsNumber(text: string): boolean {
* 检查文本中是否包含特殊符号或标点符号
*/
export function containsSpecialChars(text: string): boolean {
// 检测所有标点符号和特殊符号(包括中文标点)
return /[\p{P}\p{S}]/u.test(text)
// 检测除中文逗号、顿号、句号之外的标点符号和特殊符号
return /[\p{P}\p{S}]/u.test(text.replace(/[,、。]/g, ''))
}
/**
@@ -183,10 +130,10 @@ export function filterInput(input: string): string {
// 过滤数字
filtered = filtered.replace(/\d/g, '')
// 过滤所有标点符号和特殊符号
// 保留:中文字符、空格
// 过滤:所有标点符号(包括中文标点和英文标点)、特殊符号
filtered = filtered.replace(/[\p{P}\p{S}]/gu, '')
// 过滤其他标点符号和特殊符号
// 保留:中文字符、空格、中文逗号、顿号、句号
// 过滤:除 ,、。 之外的标点符号和特殊符号
filtered = filtered.replace(/(?![,、。])[\p{P}\p{S}]/gu, '')
// 进行敏感词过滤(替换为星号,不直接删除)
filtered = filterSensitiveWords(filtered)
+10 -10
View File
@@ -45,7 +45,7 @@ const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想
const QUIZ_ANCHORS: QuizAnchor[] = [
{
id: 'quiz-1',
time: 60 * 5 + 53, //5.52分钟
time: 60 * 5 + 53.1, //5.52分钟
title: '题目1',
prompt: '陈云经济思想的核心内容包括:',
type: 'multiple',
@@ -84,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-3',
time: 15 * 60 + 56, //15:56
time: 15 * 60 + 56.2, //15:56
title: '题目3',
prompt: '新时代的青年可以从陈云身上学习到',
type: 'drag',
@@ -102,7 +102,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
id: 'quiz-4',
time: 60 * 5 + 21, // 5:21
time: 60 * 5 + 21.5, // 5:21
title: '题目4',
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
type: 'single',
@@ -175,15 +175,13 @@ const ChenyunInternalization = () => {
once: true,
afterResume: 'remove',
render: ({ resume }) => {
// 拖拽题:处理选项拖拽与校验
const handleDragChange = (val: DragFillResult[]) => {
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
}
if (val.length < correctAnswers.length) return
// 检查是否包含了所有正确答案(不要求顺序)
const filledAnswers = val.map((v) => v.optionValue)
const handleDragSubmit = (val: DragFillResult[]) => {
const filledAnswers = val.map((result) => result.optionValue)
const answerSet = new Set(correctAnswers)
const filledSet = new Set(filledAnswers)
const isCorrect =
@@ -193,12 +191,13 @@ const ChenyunInternalization = () => {
if (isCorrect) {
setActiveSuccess(item.id)
setResumeCallback(() => resume)
} else {
return
}
setDragQuizErrors((prev) => ({
...prev,
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
}))
}
setDragQuizValues((prev) => ({ ...prev, [item.id]: [] }))
}
return (
@@ -216,6 +215,7 @@ const ChenyunInternalization = () => {
value={dragQuizValues[item.id] || []}
error={dragQuizErrors[item.id]}
onChange={handleDragChange}
onSubmit={handleDragSubmit}
/>
<SuccessAlert
visible={activeSuccess === item.id}
+2 -1
View File
@@ -177,6 +177,7 @@ const ChenyunPlace1 = () => {
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
ailgn="left"
firstLineIndent="2em"
>
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
{'\n'}
@@ -215,7 +216,7 @@ const ChenyunPlace1 = () => {
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
>
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
实际上是陈云舅父母的家,也是陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
+8 -11
View File
@@ -202,13 +202,14 @@ const ChenyunPlace7 = () => {
const handleQuiz2Change = (val: DragFillResult[]) => {
setQuiz2Value(val)
setQuiz2Error('')
if (val.length < QUIZ2_CORRECT_SET.size) return
if (!isQuiz2Correct(val)) {
setQuiz2Error(QUIZ2_ERROR)
return
}
const handleQuiz2Submit = (val: DragFillResult[]) => {
if (!isQuiz2Correct(val)) {
setQuiz2Error(QUIZ2_ERROR)
setQuiz2Value([])
return
}
setQuiz2Visible(false)
setVideo1Visible(false)
setVideo2Visible(false)
@@ -395,17 +396,13 @@ const ChenyunPlace7 = () => {
value={quiz2Value}
error={quiz2Error}
onChange={handleQuiz2Change}
onSubmit={handleQuiz2Submit}
/>
{/* 成功提示(盖在视频/题目之上) */}
{successVisible && (
<div className="fixed inset-0" style={{ zIndex: 20000 }}>
<SuccessAlert
visible
title="恭喜你, 选择正确!"
content={successContent}
onClose={handleSuccessDismiss}
/>
<SuccessAlert visible content={successContent} onClose={handleSuccessDismiss} />
</div>
)}
</div>
+50 -3
View File
@@ -1,5 +1,8 @@
import { useState, useRef } from 'react'
import { Title, Image, Cursor, Video, GuideSteps, GuideStepsItem } from '@/components'
import { SingleChoiceQuizPanel } from '@/components/layout/quiz'
import SuccessAlert from '@/components/layout/alert/success-alert'
import ScaledPortal from '@/components/layout/ScaledPortal'
import { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import type { VideoRef } from '@/components/video'
@@ -36,6 +39,10 @@ const ChenyunPlace9 = () => {
const [guideVisible, setGuideVisible] = useState(false)
const [guideStep, setGuideStep] = useState(0)
// 视频后的单选题
const [quizChenVisible, setQuizChenVisible] = useState(false)
const [successAlertChenVisible, setSuccessAlertChenVisible] = useState(false)
const handleOpenExhibit = (_index: 1) => {
// 展品1:播放视频
setVideoVisible(true)
@@ -128,15 +135,55 @@ const ChenyunPlace9 = () => {
onEnded={() => {
setVideoPlaying(false)
markExhibitVisited(place, 1)
// 视频播放完成后,显示对话引导
setGuideVisible(true)
setGuideStep(0)
// 视频播放完成后,先弹出题目
setQuizChenVisible(true)
triggerPuzzleAnimation(12)
}}
/>
</div>
)}
{/* 视频后的单选题 */}
<ScaledPortal enabled={quizChenVisible || successAlertChenVisible} zIndex={10050}>
<>
<SingleChoiceQuizPanel
visible={quizChenVisible}
zIndex={10000}
title="【单选】陈云同志在党内有“铁算盘”的称号,可他的算盘,算的从来不是个人得失,而是百姓温饱、国家根基、民族前途。请选出最能体现陈云这把“算盘”独特之处的选项:"
options={[
{ value: 'A', label: 'A.算得快、打得响、账目清' },
{ value: 'B', label: 'B.算民生、算实际、算长远' },
]}
correctAnswer="B"
onWrongSelect={(value: string) => {
switch (value) {
case 'A':
return '这是技术层面的“好算盘”,但陈云的特别,在于他把算盘打进了老百姓的米袋子、布匹铺、工厂车间里——他算的是人,不是数;是实,不是虚。请你再想想吧!'
default:
return '请你再想想吧!'
}
}}
onSubmit={(value: string, isCorrect: boolean) => {
if (isCorrect) {
setQuizChenVisible(false)
setSuccessAlertChenVisible(true)
}
}}
/>
<SuccessAlert
visible={successAlertChenVisible}
content="这就是陈云的“铁算盘”真谛。这把算盘,珠子是汗水,横梁是信念,落下的每一响,都在为中国发展校准方向。"
onContinue={() => {
setSuccessAlertChenVisible(false)
// 答对后显示对话引导
setGuideVisible(true)
setGuideStep(0)
}}
/>
</>
</ScaledPortal>
{/* 视频播放完成后的对话引导 */}
<GuideSteps
visible={guideVisible}
@@ -40,12 +40,12 @@ export const MIMIAN_CLUE_DATA: Record<
{
character: '群众1',
avatar: 'chenyun/reflection/群众1',
text: '米价一天涨三次,家里快断粮了',
text: '米价一天涨三次,家里快断粮了。',
},
{
character: '群众2',
avatar: 'chenyun/reflection/群众2',
text: '钱庄都在囤煤,冬天没法取暖',
text: '钱庄都在囤煤,冬天没法取暖。',
},
],
},
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more