chore: 更新素材资源和组件优化
- 更新陈云、鲁迅、聂耳等故居的展品图片素材 - 更新拼图细节图片 - 优化学习地图底图和详情图 - 更新音频素材 - 优化各故居 store 和页面组件
No files matched your search
@@ -8,6 +8,9 @@
|
|||||||
"name": "shanghai-residences",
|
"name": "shanghai-residences",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
@@ -339,6 +342,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 928 KiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 665 KiB After Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 556 KiB After Width: | Height: | Size: 567 KiB |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 789 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 365 KiB After Width: | Height: | Size: 365 KiB |
|
Before Width: | Height: | Size: 692 KiB After Width: | Height: | Size: 691 KiB |
|
Before Width: | Height: | Size: 510 KiB After Width: | Height: | Size: 510 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 491 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 525 KiB After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 455 KiB After Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 459 KiB After Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 418 KiB After Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 533 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 393 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 474 KiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1023 KiB After Width: | Height: | Size: 879 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 437 KiB |
|
Before Width: | Height: | Size: 762 KiB After Width: | Height: | Size: 623 KiB |
|
Before Width: | Height: | Size: 647 KiB After Width: | Height: | Size: 591 KiB |
|
Before Width: | Height: | Size: 754 KiB After Width: | Height: | Size: 764 KiB |
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 550 KiB |
|
Before Width: | Height: | Size: 819 KiB After Width: | Height: | Size: 795 KiB |
@@ -62,7 +62,7 @@ const ContinueLearningModal = ({
|
|||||||
</div>
|
</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}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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="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>
|
</div>
|
||||||
|
|
||||||
{/* 继续按钮 */}
|
{/* 继续按钮 */}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const BasketQuizPanel = ({
|
|||||||
renderOption={(option, isUsed) => (
|
renderOption={(option, isUsed) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'rounded-[32px] transition-all duration-300',
|
||||||
'text-[28px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
|
'text-[28px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
|
||||||
isUsed || filledValues.has(option.value)
|
isUsed || filledValues.has(option.value)
|
||||||
@@ -147,7 +147,7 @@ const BasketQuizPanel = ({
|
|||||||
{!(isUsed || filledValues.has(option.value)) && (
|
{!(isUsed || filledValues.has(option.value)) && (
|
||||||
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
<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}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +155,7 @@ const BasketQuizPanel = ({
|
|||||||
renderDragging={(option) => (
|
renderDragging={(option) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative min-h-[60px] px-10 flex items-center justify-center',
|
'relative min-h-[60px] px-10 flex items-center',
|
||||||
'rounded-[32px]',
|
'rounded-[32px]',
|
||||||
'text-[20px] font-bold border-3 shadow-[0_16px_48px_rgba(240,190,117,0.6)]',
|
'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]',
|
'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" />
|
<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}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,15 @@ const DragQuizPanel = ({
|
|||||||
blankSeparators,
|
blankSeparators,
|
||||||
value,
|
value,
|
||||||
error,
|
error,
|
||||||
|
feedbackType = 'error',
|
||||||
onChange,
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
submitText = '提交',
|
||||||
|
disabled = false,
|
||||||
}: DragQuizPanelProps) => {
|
}: DragQuizPanelProps) => {
|
||||||
|
const isComplete = blanks.every((blank) =>
|
||||||
|
value.some((result) => result.blankId === blank.id && result.optionValue !== '')
|
||||||
|
)
|
||||||
// 跟踪之前的 visible 状态
|
// 跟踪之前的 visible 状态
|
||||||
const prevVisibleRef = useRef(visible)
|
const prevVisibleRef = useRef(visible)
|
||||||
|
|
||||||
@@ -62,7 +69,7 @@ const DragQuizPanel = ({
|
|||||||
{filledValue ? (
|
{filledValue ? (
|
||||||
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
|
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
|
||||||
<span className="text-[28px]">{filledValue}</span>
|
<span className="text-[28px]">{filledValue}</span>
|
||||||
<button
|
{/* <button
|
||||||
type="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"
|
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) => {
|
onClick={(e) => {
|
||||||
@@ -71,7 +78,7 @@ const DragQuizPanel = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button> */}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-white/40 text-[24px] font-light">____</span>
|
<span className="text-white/40 text-[24px] font-light">____</span>
|
||||||
@@ -87,48 +94,63 @@ const DragQuizPanel = ({
|
|||||||
})}
|
})}
|
||||||
{questionSuffix && <span>{questionSuffix}</span>}
|
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 使用 DragFill 组件 */}
|
{/* 选项区域 */}
|
||||||
<div className="w-[1500px] mt-16">
|
<div className="w-full max-w-[1300px] mt-10">
|
||||||
<DragFill
|
<DragFill
|
||||||
options={options}
|
options={options}
|
||||||
blanks={blanks}
|
blanks={blanks}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
hideLabels
|
hideLabels
|
||||||
showSubmitButton={false}
|
showSubmitButton={false}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
className="w-full"
|
className="w-full h-auto!"
|
||||||
|
optionClassName="grid grid-cols-2 gap-5"
|
||||||
renderOption={(option, isUsed) => (
|
renderOption={(option, isUsed) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-[110px] px-10 flex items-center justify-center',
|
'px-10 py-4 flex items-center justify-center',
|
||||||
'rounded-[32px] transition-all duration-300',
|
'rounded-2xl transition-all duration-300',
|
||||||
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
|
'text-[30px] font-bold border-2',
|
||||||
|
'shadow-[0_4px_16px_rgba(0,0,0,0.25)]',
|
||||||
isUsed
|
isUsed
|
||||||
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
|
? '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 && (
|
<span className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">{option.label}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
renderBlank={(_blank) => {
|
renderBlank={(_blank) => {
|
||||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
|
||||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
|
||||||
return <div className="hidden" />
|
return <div className="hidden" />
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</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
|
||||||
@@ -5,6 +5,8 @@ export { default as DragQuizPanel } from './DragQuizPanel'
|
|||||||
export { default as BasketQuizPanel } from './BasketQuizPanel'
|
export { default as BasketQuizPanel } from './BasketQuizPanel'
|
||||||
export { default as MatchQuizPanel } from './MatchQuizPanel'
|
export { default as MatchQuizPanel } from './MatchQuizPanel'
|
||||||
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
|
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
|
||||||
|
export { default as SortQuizPanel } from './SortQuizPanel'
|
||||||
|
export { default as ErrorCorrectionQuizPanel } from './ErrorCorrectionQuizPanel'
|
||||||
export type {
|
export type {
|
||||||
SingleChoiceQuizPanelProps,
|
SingleChoiceQuizPanelProps,
|
||||||
MultipleChoiceQuizPanelProps,
|
MultipleChoiceQuizPanelProps,
|
||||||
@@ -15,6 +17,10 @@ export type {
|
|||||||
SelectFillQuizPanelProps,
|
SelectFillQuizPanelProps,
|
||||||
SelectFillBlank,
|
SelectFillBlank,
|
||||||
SelectFillResult,
|
SelectFillResult,
|
||||||
|
SortQuizPanelProps,
|
||||||
|
SortQuizItem,
|
||||||
|
ErrorCorrectionQuizPanelProps,
|
||||||
|
ErrorCorrectionBlock,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// 保留旧组件以保持向后兼容
|
// 保留旧组件以保持向后兼容
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export interface DragQuizPanelProps {
|
|||||||
feedbackType?: 'success' | 'error'
|
feedbackType?: 'success' | 'error'
|
||||||
/** 值变化回调 */
|
/** 值变化回调 */
|
||||||
onChange: (value: DragFillResult[]) => void
|
onChange: (value: DragFillResult[]) => void
|
||||||
|
/** 所有空白填满后点击提交的回调 */
|
||||||
|
onSubmit?: (value: DragFillResult[]) => void
|
||||||
|
/** 提交按钮文本 */
|
||||||
|
submitText?: string
|
||||||
|
/** 是否禁用拖拽和提交 */
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单选题面板配置 */
|
/** 单选题面板配置 */
|
||||||
@@ -192,6 +198,72 @@ export interface SelectFillQuizPanelProps {
|
|||||||
submitText?: string
|
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 替代 */
|
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||||
export interface QuizPanelProps {
|
export interface QuizPanelProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ const LetterTemplate = ({
|
|||||||
} else if (/\d/.test(value)) {
|
} else if (/\d/.test(value)) {
|
||||||
showHint('不能包含数字')
|
showHint('不能包含数字')
|
||||||
} else if (/[\p{P}\p{S}]/u.test(value)) {
|
} else if (/[\p{P}\p{S}]/u.test(value)) {
|
||||||
showHint('不能包含标点符号')
|
showHint('不能包含特殊符号')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ const LetterTemplate = ({
|
|||||||
} else if (/\d/.test(value)) {
|
} else if (/\d/.test(value)) {
|
||||||
showHint('不能包含数字')
|
showHint('不能包含数字')
|
||||||
} else if (/[\p{P}\p{S}]/u.test(value)) {
|
} 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="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
|
<div className="flex flex-col items-center text-white text-6xl font-bold leading-none gap-[20px]">
|
||||||
className="text-white text-6xl font-bold tracking-[20px]"
|
{Array.from(title).map((char, index) => (
|
||||||
style={{
|
<span key={`title-${index}`}>{char}</span>
|
||||||
writingMode: 'vertical-rl',
|
))}
|
||||||
textOrientation: 'upright',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,21 @@ const fields: Record<number, LetterFieldConfig> = {
|
|||||||
3: {
|
3: {
|
||||||
type: 'input',
|
type: 'input',
|
||||||
maxLength: 12,
|
maxLength: 12,
|
||||||
widthClassName: 'w-[180px]',
|
widthClassName: 'w-[120px]',
|
||||||
placeholder: '结合所学专业',
|
|
||||||
readOnlyFallback: '专业',
|
readOnlyFallback: '专业',
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
type: 'input',
|
type: 'input',
|
||||||
maxLength: 12,
|
maxLength: 12,
|
||||||
widthClassName: 'w-[120px]',
|
widthClassName: 'w-[180px]',
|
||||||
|
placeholder: '结合所学专业',
|
||||||
readOnlyFallback: '实干报国',
|
readOnlyFallback: '实干报国',
|
||||||
},
|
},
|
||||||
|
5: {
|
||||||
|
type: 'input',
|
||||||
|
maxLength: 12,
|
||||||
|
widthClassName: 'w-[120px]',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const LetterToChenyun = ({
|
const LetterToChenyun = ({
|
||||||
|
|||||||
@@ -575,8 +575,14 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
|||||||
|
|
||||||
// 计算进度百分比
|
// 计算进度百分比
|
||||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
const disableFirstWatchVideoGating =
|
|
||||||
|
// 视频首次观看限制:env 默认值可被调试面板运行时覆盖
|
||||||
|
const envDisableGating =
|
||||||
import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
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 canShowSkipButton = !skipKey || disableFirstWatchVideoGating || isVideoCompleted
|
||||||
const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted
|
const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted
|
||||||
const shouldShowReadonlyProgress =
|
const shouldShowReadonlyProgress =
|
||||||
@@ -642,6 +648,43 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
|||||||
style={{ transform: `scaleX(${progressPercent / 100})` }}
|
style={{ transform: `scaleX(${progressPercent / 100})` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 跳过按钮 */}
|
{/* 跳过按钮 */}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
|
|||||||
route: 'place4',
|
route: 'place4',
|
||||||
},
|
},
|
||||||
13: {
|
13: {
|
||||||
title: '冯雪峰与鲁迅谈话的资料图',
|
title: '鲁迅全家与冯雪峰全家合影',
|
||||||
detailImage: 'luxun/details/13',
|
detailImage: 'luxun/details/13',
|
||||||
route: 'place4',
|
route: 'place4',
|
||||||
},
|
},
|
||||||
@@ -419,7 +419,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
|
|||||||
route: 'place4',
|
route: 'place4',
|
||||||
},
|
},
|
||||||
9: {
|
9: {
|
||||||
title: '中共四大立碑处',
|
title: '中共四大遗址立碑处',
|
||||||
detailImage: 'maozedong/details/9',
|
detailImage: 'maozedong/details/9',
|
||||||
route: 'place4',
|
route: 'place4',
|
||||||
},
|
},
|
||||||
@@ -476,7 +476,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
|
|||||||
route: 'place1',
|
route: 'place1',
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
title: '英雄儿女胶片',
|
title: '风云儿女胶片',
|
||||||
detailImage: 'nieer/details/4',
|
detailImage: 'nieer/details/4',
|
||||||
route: 'place2',
|
route: 'place2',
|
||||||
},
|
},
|
||||||
@@ -486,7 +486,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
|
|||||||
route: 'place2',
|
route: 'place2',
|
||||||
},
|
},
|
||||||
6: {
|
6: {
|
||||||
title: '聂耳手写《义勇军进行曲》',
|
title: '《义勇军进行曲》\n曲谱的手稿',
|
||||||
detailImage: 'nieer/details/6',
|
detailImage: 'nieer/details/6',
|
||||||
route: 'place3',
|
route: 'place3',
|
||||||
},
|
},
|
||||||
@@ -526,7 +526,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
|
|||||||
route: 'place6',
|
route: 'place6',
|
||||||
},
|
},
|
||||||
14: {
|
14: {
|
||||||
title: '唱国歌一定要站起来',
|
title: '定格阅兵瞬间',
|
||||||
detailImage: 'nieer/details/14',
|
detailImage: 'nieer/details/14',
|
||||||
route: 'reflection',
|
route: 'reflection',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { puzzleDataConfig } from '@/config/puzzleData'
|
import { puzzleDataConfig } from '@/config/puzzleData'
|
||||||
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
import { savePuzzleStore } from '@/store/puzzleStore'
|
||||||
|
|
||||||
const MODE_KEY = 'debug:progression-mode'
|
const MODE_KEY = 'debug:progression-mode'
|
||||||
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
|
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
|
||||||
@@ -20,6 +21,21 @@ type BadgePosition = {
|
|||||||
offset: number
|
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>>) => {
|
const serializePuzzles = (puzzles: Record<string, Set<number>>) => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]])
|
Object.entries(puzzles).map(([residence, pieces]) => [residence, [...pieces]])
|
||||||
@@ -62,6 +78,91 @@ const buildAllCompletedPuzzles = () => {
|
|||||||
return puzzles
|
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 getStoredMode = (): DebugMode => {
|
||||||
const raw = window.sessionStorage.getItem(MODE_KEY)
|
const raw = window.sessionStorage.getItem(MODE_KEY)
|
||||||
if (!raw) return { type: 'real' }
|
if (!raw) return { type: 'real' }
|
||||||
@@ -91,6 +192,10 @@ const getModeLabel = (mode = getStoredMode()) => {
|
|||||||
return '真实进度'
|
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 clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||||
|
|
||||||
const getStoredBadgePosition = (): BadgePosition => {
|
const getStoredBadgePosition = (): BadgePosition => {
|
||||||
@@ -224,6 +329,63 @@ const removePanel = () => {
|
|||||||
document.getElementById(PANEL_ID)?.remove()
|
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 togglePanel = () => {
|
||||||
const existingPanel = document.getElementById(PANEL_ID)
|
const existingPanel = document.getElementById(PANEL_ID)
|
||||||
if (existingPanel) {
|
if (existingPanel) {
|
||||||
@@ -237,7 +399,7 @@ const togglePanel = () => {
|
|||||||
panel.style.right = '12px'
|
panel.style.right = '12px'
|
||||||
panel.style.top = '72px'
|
panel.style.top = '72px'
|
||||||
panel.style.zIndex = '99999'
|
panel.style.zIndex = '99999'
|
||||||
panel.style.width = '260px'
|
panel.style.width = '320px'
|
||||||
panel.style.maxHeight = '70vh'
|
panel.style.maxHeight = '70vh'
|
||||||
panel.style.overflowY = 'auto'
|
panel.style.overflowY = 'auto'
|
||||||
panel.style.padding = '10px'
|
panel.style.padding = '10px'
|
||||||
@@ -254,11 +416,86 @@ const togglePanel = () => {
|
|||||||
panel.appendChild(title)
|
panel.appendChild(title)
|
||||||
|
|
||||||
const description = document.createElement('div')
|
const description = document.createElement('div')
|
||||||
description.textContent = '只写本地 puzzleStore,不调用易班保存。'
|
description.textContent = '只写本地 store,不调用易班保存。点击阶段按钮即时生效。'
|
||||||
description.style.opacity = '0.75'
|
description.style.opacity = '0.75'
|
||||||
description.style.marginBottom = '8px'
|
description.style.marginBottom = '6px'
|
||||||
panel.appendChild(description)
|
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')
|
const list = document.createElement('div')
|
||||||
list.style.display = 'flex'
|
list.style.display = 'flex'
|
||||||
list.style.flexDirection = 'column'
|
list.style.flexDirection = 'column'
|
||||||
@@ -266,14 +503,73 @@ const togglePanel = () => {
|
|||||||
|
|
||||||
list.appendChild(createButton('真实进度', () => applyMode({ type: 'real' })))
|
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) {
|
for (const residence of PUZZLE_RESIDENCES) {
|
||||||
list.appendChild(
|
// 名称行 + 到达按钮
|
||||||
createButton(`到达:${residence.displayName}`, () =>
|
const row1 = document.createElement('div')
|
||||||
applyMode({ type: 'arrived', residence: residence.residence, label: residence.displayName })
|
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' })))
|
list.appendChild(createButton('全流程完成', () => applyMode({ type: 'allCompleted' })))
|
||||||
|
|
||||||
panel.appendChild(list)
|
panel.appendChild(list)
|
||||||
|
|||||||
@@ -3,11 +3,8 @@ export { usePageScale } from './usePageScale'
|
|||||||
export { useTouch } from './useTouch'
|
export { useTouch } from './useTouch'
|
||||||
export { useZoomAndDrag } from './useZoomAndDrag'
|
export { useZoomAndDrag } from './useZoomAndDrag'
|
||||||
export { useCursorController } from './useCursorController'
|
export { useCursorController } from './useCursorController'
|
||||||
export { useResidenceContinue } from './useResidenceContinue'
|
export { useResidenceContinue, setPreviousPath } from './useResidenceContinue'
|
||||||
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
|
export type { UseTouchOptions, UseTouchReturn, TouchEventData, SwipeDirection } from './useTouch'
|
||||||
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
|
export type { UseZoomAndDragOptions, UseZoomAndDragReturn } from './useZoomAndDrag'
|
||||||
export type { CursorControllerConfig } from './useCursorController'
|
export type { CursorControllerConfig } from './useCursorController'
|
||||||
export type {
|
export type { UseResidenceContinueParams, UseResidenceContinueResult } from './useResidenceContinue'
|
||||||
UseResidenceContinueParams,
|
|
||||||
UseResidenceContinueResult,
|
|
||||||
} from './useResidenceContinue'
|
|
||||||
@@ -16,11 +16,16 @@ import { useVideoContextSafe } from '@/contexts/VideoContext'
|
|||||||
const promptedResidences = new Set<string>()
|
const promptedResidences = new Set<string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录上一次访问的路由,用于判断是否刚从场景离开。
|
* 记录上次 ResidenceLayout 访问的路径,由 ResidenceLayout 在路由变化时更新。
|
||||||
* 模块级变量,跨 hook 实例共享。
|
* 用于判断 Home 页面之前是否来自本故居的 place/reflection/internalization/draw 场景。
|
||||||
*/
|
*/
|
||||||
let previousPath = ''
|
let previousPath = ''
|
||||||
|
|
||||||
|
/** 由 ResidenceLayout 调用,记录每次路由变化的路径 */
|
||||||
|
export const setPreviousPath = (path: string) => {
|
||||||
|
previousPath = path
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseResidenceContinueParams {
|
export interface UseResidenceContinueParams {
|
||||||
/** 故居标识(同时也是路由里的 starName),如 'maozedong' */
|
/** 故居标识(同时也是路由里的 starName),如 'maozedong' */
|
||||||
residence: string
|
residence: string
|
||||||
@@ -50,7 +55,7 @@ export interface UseResidenceContinueResult {
|
|||||||
* 3. 本会话内该故居尚未自动弹过
|
* 3. 本会话内该故居尚未自动弹过
|
||||||
* 4. 故居已解锁 && 已开始 && 未全部完成
|
* 4. 故居已解锁 && 已开始 && 未全部完成
|
||||||
* 5. 能解析出有效的续学目标
|
* 5. 能解析出有效的续学目标
|
||||||
* 6. 不是刚从本故居场景页面离开的(避免干扰正常流程)
|
* 6. 上一页不是本故居的 place/reflection/internalization/draw(避免从场景返回时误弹)
|
||||||
*
|
*
|
||||||
* 弹一次后即标记,之后再回到 home 不再自动弹(由首页的三个阶段按钮继续)。
|
* 弹一次后即标记,之后再回到 home 不再自动弹(由首页的三个阶段按钮继续)。
|
||||||
*/
|
*/
|
||||||
@@ -79,21 +84,15 @@ export function useResidenceContinue({
|
|||||||
// 没开始过的新故居不打扰,让学生自己点「一」正常开始
|
// 没开始过的新故居不打扰,让学生自己点「一」正常开始
|
||||||
if (!isResidenceStarted(residence, puzzles)) return
|
if (!isResidenceStarted(residence, puzzles)) return
|
||||||
|
|
||||||
// 检查上一个路由:如果是从本故居的 place/reflection/internalization 来的,不弹
|
// 如果上一页是本故居的 place/reflection/internalization/draw,不弹
|
||||||
// 例如从 /revolutionary/maozedong/place1 回到 /revolutionary/maozedong/home
|
// 例如从 /revolutionary/maozedong/place4 回到 /revolutionary/maozedong/home
|
||||||
const currentPath = location.pathname
|
if (
|
||||||
const isFromResidenceScene =
|
|
||||||
previousPath &&
|
previousPath &&
|
||||||
(previousPath.includes(`/${residence}/place`) ||
|
(previousPath.includes(`/${residence}/place`) ||
|
||||||
previousPath.includes(`/${residence}/reflection`) ||
|
previousPath.includes(`/${residence}/reflection`) ||
|
||||||
previousPath.includes(`/${residence}/internalization`) ||
|
previousPath.includes(`/${residence}/internalization`) ||
|
||||||
previousPath.includes(`/${residence}/draw`))
|
previousPath.includes(`/${residence}/draw`))
|
||||||
|
) {
|
||||||
// 更新 previousPath 供下次判断
|
|
||||||
previousPath = currentPath
|
|
||||||
|
|
||||||
if (isFromResidenceScene) {
|
|
||||||
// 刚从场景离开,不弹窗
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,17 @@ export async function getUserDefineResult(
|
|||||||
return getUserDefineResultAsyncInternal(lessonId, key, options)
|
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> {
|
export async function setCourseTimeProgress(order: number): Promise<boolean> {
|
||||||
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
|
if (useUserStore.getState().yibanConnectionStatus !== 'connected') {
|
||||||
|
|||||||
@@ -3,60 +3,7 @@
|
|||||||
* 用于过滤用户输入中的敏感词汇,将其替换为 *
|
* 用于过滤用户输入中的敏感词汇,将其替换为 *
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 敏感词库(可根据实际需求扩展)
|
import { SENSITIVE_WORDS } from './sensitiveWordList'
|
||||||
const SENSITIVE_WORDS = [
|
|
||||||
// 基础不文明用语
|
|
||||||
'垃圾',
|
|
||||||
'脏话',
|
|
||||||
'骂人',
|
|
||||||
'辱骂',
|
|
||||||
'仇恨',
|
|
||||||
'傻逼',
|
|
||||||
'煞笔',
|
|
||||||
'傻b',
|
|
||||||
'傻比',
|
|
||||||
'sb',
|
|
||||||
'SB',
|
|
||||||
'草泥马',
|
|
||||||
'操',
|
|
||||||
'妈的',
|
|
||||||
'他妈',
|
|
||||||
'你妈',
|
|
||||||
'去死',
|
|
||||||
'找死',
|
|
||||||
'该死',
|
|
||||||
'白痴',
|
|
||||||
'智障',
|
|
||||||
'弱智',
|
|
||||||
'脑残',
|
|
||||||
'废物',
|
|
||||||
'贱',
|
|
||||||
'滚',
|
|
||||||
// 暴力相关
|
|
||||||
'暴力',
|
|
||||||
'恐怖',
|
|
||||||
'杀人',
|
|
||||||
'杀害',
|
|
||||||
'血腥',
|
|
||||||
'虐待',
|
|
||||||
// 违法违规
|
|
||||||
'色情',
|
|
||||||
'赌博',
|
|
||||||
'诈骗',
|
|
||||||
'毒品',
|
|
||||||
'邪教',
|
|
||||||
'反动',
|
|
||||||
'法轮功',
|
|
||||||
'传销',
|
|
||||||
'洗钱',
|
|
||||||
'贩毒',
|
|
||||||
'走私',
|
|
||||||
// 歧视性用语
|
|
||||||
'歧视',
|
|
||||||
'种族',
|
|
||||||
'民族歧视',
|
|
||||||
]
|
|
||||||
|
|
||||||
type FileSaveHandle = {
|
type FileSaveHandle = {
|
||||||
createWritable: () => Promise<{
|
createWritable: () => Promise<{
|
||||||
write: (data: Blob) => Promise<void>
|
write: (data: Blob) => Promise<void>
|
||||||
@@ -132,8 +79,8 @@ export function containsNumber(text: string): boolean {
|
|||||||
* 检查文本中是否包含特殊符号或标点符号
|
* 检查文本中是否包含特殊符号或标点符号
|
||||||
*/
|
*/
|
||||||
export function containsSpecialChars(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(/\d/g, '')
|
||||||
|
|
||||||
// 过滤所有标点符号和特殊符号
|
// 过滤其他标点符号和特殊符号
|
||||||
// 保留:中文字符、空格
|
// 保留:中文字符、空格、中文逗号、顿号、句号
|
||||||
// 过滤:所有标点符号(包括中文标点和英文标点)、特殊符号
|
// 过滤:除 ,、。 之外的标点符号和特殊符号
|
||||||
filtered = filtered.replace(/[\p{P}\p{S}]/gu, '')
|
filtered = filtered.replace(/(?![,、。])[\p{P}\p{S}]/gu, '')
|
||||||
|
|
||||||
// 进行敏感词过滤(替换为星号,不直接删除)
|
// 进行敏感词过滤(替换为星号,不直接删除)
|
||||||
filtered = filterSensitiveWords(filtered)
|
filtered = filterSensitiveWords(filtered)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const MULTIPLE_DEFAULT_ERROR = '你的选择还不够全面哦~请你再想想
|
|||||||
const QUIZ_ANCHORS: QuizAnchor[] = [
|
const QUIZ_ANCHORS: QuizAnchor[] = [
|
||||||
{
|
{
|
||||||
id: 'quiz-1',
|
id: 'quiz-1',
|
||||||
time: 60 * 5 + 53, //5.52分钟
|
time: 60 * 5 + 53.1, //5.52分钟
|
||||||
title: '题目1',
|
title: '题目1',
|
||||||
prompt: '陈云经济思想的核心内容包括:',
|
prompt: '陈云经济思想的核心内容包括:',
|
||||||
type: 'multiple',
|
type: 'multiple',
|
||||||
@@ -84,7 +84,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quiz-3',
|
id: 'quiz-3',
|
||||||
time: 15 * 60 + 56, //15:56
|
time: 15 * 60 + 56.2, //15:56
|
||||||
title: '题目3',
|
title: '题目3',
|
||||||
prompt: '新时代的青年可以从陈云身上学习到',
|
prompt: '新时代的青年可以从陈云身上学习到',
|
||||||
type: 'drag',
|
type: 'drag',
|
||||||
@@ -102,7 +102,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quiz-4',
|
id: 'quiz-4',
|
||||||
time: 60 * 5 + 21, // 5:21
|
time: 60 * 5 + 21.5, // 5:21
|
||||||
title: '题目4',
|
title: '题目4',
|
||||||
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
|
prompt: '陈云讲:“经济建设最终目的,是为了改善____的生活水平。”',
|
||||||
type: 'single',
|
type: 'single',
|
||||||
@@ -175,15 +175,13 @@ const ChenyunInternalization = () => {
|
|||||||
once: true,
|
once: true,
|
||||||
afterResume: 'remove',
|
afterResume: 'remove',
|
||||||
render: ({ resume }) => {
|
render: ({ resume }) => {
|
||||||
// 拖拽题:处理选项拖拽与校验
|
|
||||||
const handleDragChange = (val: DragFillResult[]) => {
|
const handleDragChange = (val: DragFillResult[]) => {
|
||||||
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
|
setDragQuizValues((prev) => ({ ...prev, [item.id]: val }))
|
||||||
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
|
setDragQuizErrors((prev) => ({ ...prev, [item.id]: '' }))
|
||||||
|
}
|
||||||
|
|
||||||
if (val.length < correctAnswers.length) return
|
const handleDragSubmit = (val: DragFillResult[]) => {
|
||||||
|
const filledAnswers = val.map((result) => result.optionValue)
|
||||||
// 检查是否包含了所有正确答案(不要求顺序)
|
|
||||||
const filledAnswers = val.map((v) => v.optionValue)
|
|
||||||
const answerSet = new Set(correctAnswers)
|
const answerSet = new Set(correctAnswers)
|
||||||
const filledSet = new Set(filledAnswers)
|
const filledSet = new Set(filledAnswers)
|
||||||
const isCorrect =
|
const isCorrect =
|
||||||
@@ -193,12 +191,13 @@ const ChenyunInternalization = () => {
|
|||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
setActiveSuccess(item.id)
|
setActiveSuccess(item.id)
|
||||||
setResumeCallback(() => resume)
|
setResumeCallback(() => resume)
|
||||||
} else {
|
return
|
||||||
setDragQuizErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
setDragQuizErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[item.id]: '这不是陈云的工作作风。请你再想想吧!',
|
||||||
|
}))
|
||||||
|
setDragQuizValues((prev) => ({ ...prev, [item.id]: [] }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,6 +215,7 @@ const ChenyunInternalization = () => {
|
|||||||
value={dragQuizValues[item.id] || []}
|
value={dragQuizValues[item.id] || []}
|
||||||
error={dragQuizErrors[item.id]}
|
error={dragQuizErrors[item.id]}
|
||||||
onChange={handleDragChange}
|
onChange={handleDragChange}
|
||||||
|
onSubmit={handleDragSubmit}
|
||||||
/>
|
/>
|
||||||
<SuccessAlert
|
<SuccessAlert
|
||||||
visible={activeSuccess === item.id}
|
visible={activeSuccess === item.id}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ const ChenyunPlace1 = () => {
|
|||||||
}}
|
}}
|
||||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
|
||||||
ailgn="left"
|
ailgn="left"
|
||||||
|
firstLineIndent="2em"
|
||||||
>
|
>
|
||||||
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
1.场景中出现动态光点的展品表示未浏览,可通过点击光点来了解展品背后的故事,通过滚轮可以放大展品,浏览展品后蓝色动态光点会变为已浏览状态。
|
||||||
{'\n'}
|
{'\n'}
|
||||||
@@ -215,7 +216,7 @@ const ChenyunPlace1 = () => {
|
|||||||
speaker="guide"
|
speaker="guide"
|
||||||
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
|
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
|
||||||
>
|
>
|
||||||
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
|
实际上是陈云舅父母的家,也是陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
|
||||||
</GuideStepsItem>
|
</GuideStepsItem>
|
||||||
<GuideStepsItem
|
<GuideStepsItem
|
||||||
speaker="guide"
|
speaker="guide"
|
||||||
|
|||||||
@@ -202,13 +202,14 @@ const ChenyunPlace7 = () => {
|
|||||||
const handleQuiz2Change = (val: DragFillResult[]) => {
|
const handleQuiz2Change = (val: DragFillResult[]) => {
|
||||||
setQuiz2Value(val)
|
setQuiz2Value(val)
|
||||||
setQuiz2Error('')
|
setQuiz2Error('')
|
||||||
if (val.length < QUIZ2_CORRECT_SET.size) return
|
}
|
||||||
|
|
||||||
|
const handleQuiz2Submit = (val: DragFillResult[]) => {
|
||||||
if (!isQuiz2Correct(val)) {
|
if (!isQuiz2Correct(val)) {
|
||||||
setQuiz2Error(QUIZ2_ERROR)
|
setQuiz2Error(QUIZ2_ERROR)
|
||||||
|
setQuiz2Value([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuiz2Visible(false)
|
setQuiz2Visible(false)
|
||||||
setVideo1Visible(false)
|
setVideo1Visible(false)
|
||||||
setVideo2Visible(false)
|
setVideo2Visible(false)
|
||||||
@@ -395,17 +396,13 @@ const ChenyunPlace7 = () => {
|
|||||||
value={quiz2Value}
|
value={quiz2Value}
|
||||||
error={quiz2Error}
|
error={quiz2Error}
|
||||||
onChange={handleQuiz2Change}
|
onChange={handleQuiz2Change}
|
||||||
|
onSubmit={handleQuiz2Submit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 成功提示(盖在视频/题目之上) */}
|
{/* 成功提示(盖在视频/题目之上) */}
|
||||||
{successVisible && (
|
{successVisible && (
|
||||||
<div className="fixed inset-0" style={{ zIndex: 20000 }}>
|
<div className="fixed inset-0" style={{ zIndex: 20000 }}>
|
||||||
<SuccessAlert
|
<SuccessAlert visible content={successContent} onClose={handleSuccessDismiss} />
|
||||||
visible
|
|
||||||
title="恭喜你, 选择正确!"
|
|
||||||
content={successContent}
|
|
||||||
onClose={handleSuccessDismiss}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Title, Image, Cursor, Video, GuideSteps, GuideStepsItem } from '@/components'
|
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 { getBackgroundImage, getStaticImage, getPublicPath } from '@/lib/utils'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import type { VideoRef } from '@/components/video'
|
import type { VideoRef } from '@/components/video'
|
||||||
@@ -36,6 +39,10 @@ const ChenyunPlace9 = () => {
|
|||||||
const [guideVisible, setGuideVisible] = useState(false)
|
const [guideVisible, setGuideVisible] = useState(false)
|
||||||
const [guideStep, setGuideStep] = useState(0)
|
const [guideStep, setGuideStep] = useState(0)
|
||||||
|
|
||||||
|
// 视频后的单选题
|
||||||
|
const [quizChenVisible, setQuizChenVisible] = useState(false)
|
||||||
|
const [successAlertChenVisible, setSuccessAlertChenVisible] = useState(false)
|
||||||
|
|
||||||
const handleOpenExhibit = (_index: 1) => {
|
const handleOpenExhibit = (_index: 1) => {
|
||||||
// 展品1:播放视频
|
// 展品1:播放视频
|
||||||
setVideoVisible(true)
|
setVideoVisible(true)
|
||||||
@@ -128,15 +135,55 @@ const ChenyunPlace9 = () => {
|
|||||||
onEnded={() => {
|
onEnded={() => {
|
||||||
setVideoPlaying(false)
|
setVideoPlaying(false)
|
||||||
markExhibitVisited(place, 1)
|
markExhibitVisited(place, 1)
|
||||||
// 视频播放完成后,显示对话引导
|
// 视频播放完成后,先弹出题目
|
||||||
setGuideVisible(true)
|
setQuizChenVisible(true)
|
||||||
setGuideStep(0)
|
|
||||||
triggerPuzzleAnimation(12)
|
triggerPuzzleAnimation(12)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<GuideSteps
|
||||||
visible={guideVisible}
|
visible={guideVisible}
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ export const MIMIAN_CLUE_DATA: Record<
|
|||||||
{
|
{
|
||||||
character: '群众1',
|
character: '群众1',
|
||||||
avatar: 'chenyun/reflection/群众1',
|
avatar: 'chenyun/reflection/群众1',
|
||||||
text: '米价一天涨三次,家里快断粮了',
|
text: '米价一天涨三次,家里快断粮了。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
character: '群众2',
|
character: '群众2',
|
||||||
avatar: 'chenyun/reflection/群众2',
|
avatar: 'chenyun/reflection/群众2',
|
||||||
text: '钱庄都在囤煤,冬天没法取暖',
|
text: '钱庄都在囤煤,冬天没法取暖。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||