feat: update residence learning flows

Commit non-media application and asset changes while leaving audio, video, and environment files out of version control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-13 10:15:26 +08:00
1 parent 68ba22a33f
commit 6d40e19346
184 files changed
+7663 -2203

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

+8 -1
View File
@@ -7,9 +7,11 @@
## 需要的图片文件列表
### 1. 首页
- `鲁迅-首页.png` - 首页背景图(放在 luxun 根目录)
### 2. place1 - 风雨前夕的思想淬炼(探索鲁迅故居一楼客厅)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -18,6 +20,7 @@
- `展品5.png` - 展品5图片
### 3. place2 - 杂文集里的匕首与投枪(探索卧室兼书房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -25,6 +28,7 @@
- `展品4.png` - 展品4图片
### 4. place3 - 历史新编中的现实呐喊(探索二楼卧室兼书房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
@@ -32,21 +36,25 @@
- `展品4.png` - 展品4图片
### 5. place4 - 为亡友编纂的精神丰碑(探索二楼卧室兼书房的书桌)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片
### 6. place5 - 6平方米的红色庇护所(探索鲁迅故居的客房)
- `背景.png` - 场景背景图
- `展品1.png` - 展品1图片
- `展品2.png` - 展品2图片
- `展品3.png` - 展品3图片
### 7. reflection - 体悟模块
- `底图.png` - 体悟模块背景图
### 8. internalization - 内化模块
- `底图.png` - 内化模块背景图
## 注意事项
@@ -54,4 +62,3 @@
1. 所有图片文件请使用 PNG 格式
2. 文件名请严格按照上述命名,区分大小写
3. 入口页的鲁迅图标文件已存在于 `entrance/鲁迅.png`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 711 KiB

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 455 KiB

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 KiB

After

Width:  |  Height:  |  Size: 811 KiB

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

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 34 KiB

+17 -22
View File
@@ -47,30 +47,25 @@ src/components/
## 组件模板
````tsx
import React, {
forwardRef,
useRef,
useState,
useImperativeHandle,
} from "react";
import { cn } from "@/lib/utils";
import React, { forwardRef, useRef, useState, useImperativeHandle } from 'react'
import { cn } from '@/lib/utils'
/** 组件配置项 */
export interface ComponentNameProps {
/** 必填属性说明 */
requiredProp: string;
requiredProp: string
/** 可选属性说明 */
optionalProp?: boolean;
optionalProp?: boolean
/** 子元素 */
children?: React.ReactNode;
children?: React.ReactNode
/** 自定义类名 */
className?: string;
className?: string
}
/** 组件 Ref 暴露的方法 */
export interface ComponentNameRef {
/** 方法说明 */
methodName: () => void;
methodName: () => void
}
/**
@@ -84,33 +79,33 @@ export interface ComponentNameRef {
const ComponentName = forwardRef<ComponentNameRef, ComponentNameProps>(
({ requiredProp, optionalProp = false, className, children }, ref) => {
// 状态定义
const [state, setState] = useState<string>("");
const [state, setState] = useState<string>('')
// Ref 定义
const innerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null)
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
methodName: () => {
// 实现
},
}));
}))
return <div className={cn("base-class", className)}>{children}</div>;
return <div className={cn('base-class', className)}>{children}</div>
}
);
)
ComponentName.displayName = "ComponentName";
ComponentName.displayName = 'ComponentName'
export default ComponentName;
export default ComponentName
````
## index.ts 导出模板
```ts
export { default } from "./component-name";
export { default as ComponentName } from "./component-name";
export type { ComponentNameProps, ComponentNameRef } from "./component-name";
export { default } from './component-name'
export { default as ComponentName } from './component-name'
export type { ComponentNameProps, ComponentNameRef } from './component-name'
```
## 类型规范
+21 -3
View File
@@ -79,6 +79,9 @@ const ExhibitOverlay = ({
const containerRef = useRef<HTMLDivElement | null>(null)
const viewElRef = useRef<HTMLDivElement | null>(null)
const frameRef = useRef<number | null>(null)
const openTimerRef = useRef<number | null>(null)
const wasVisibleRef = useRef(false)
const onOpenRef = useRef(onOpen)
const pointers = useRef<Map<number, Point>>(new Map())
const lastDistance = useRef<number | null>(null)
// 拖拽初始状态:记录 pointerDown 时的状态
@@ -137,10 +140,25 @@ const ExhibitOverlay = ({
}, [applyTransformStyle])
useEffect(() => {
if (visible) {
onOpen?.()
onOpenRef.current = onOpen
}, [onOpen])
useEffect(() => {
if (visible && !wasVisibleRef.current) {
openTimerRef.current = window.setTimeout(() => {
onOpenRef.current?.()
openTimerRef.current = null
}, 0)
}
}, [visible, onOpen])
wasVisibleRef.current = visible
return () => {
if (openTimerRef.current) {
window.clearTimeout(openTimerRef.current)
openTimerRef.current = null
}
}
}, [visible])
// 关闭或切换时重置
useEffect(() => {
+8 -1
View File
@@ -149,6 +149,13 @@ export const StudyGuideModal = ({
// }
}
const handleContinue = () => {
if (audioSrc && currentSrc === audioSrc) {
stop()
}
onContinue?.()
}
const contentChildren = renderIndentedContent(children, firstLineIndent)
const content = (
@@ -204,7 +211,7 @@ export const StudyGuideModal = ({
src={getStaticImage('components/guide/继续')}
alt="继续"
button
onClick={onContinue}
onClick={handleContinue}
/>
</div>
</div>
+41 -36
View File
@@ -23,6 +23,7 @@ const RESIDENCES_WITH_PUZZLE = [
'maozedong',
'nieer',
'zhangleping',
'rongdesheng',
] as const
/**
@@ -186,48 +187,52 @@ const ResidenceLayoutContent = () => {
// 处理碎片动画触发(仅支持拼图功能的故居)
const handleTriggerPuzzleAnimation = useCallback(
(pieceId: number, options?: { onComplete?: () => void }) => {
if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
return Promise.resolve()
}
// 防止重复执行动画
if (animationInProgressRef.current) {
options?.onComplete?.()
return Promise.resolve()
}
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) {
options?.onComplete?.()
return Promise.resolve()
}
// 标记动画正在进行
animationInProgressRef.current = true
return new Promise<void>((resolve) => {
animationCompletionRef.current = resolve
animationCompletionCallbackRef.current = options?.onComplete ?? null
window.setTimeout(() => {
if (!residence || !hasPuzzleFeature) {
options?.onComplete?.()
resolve()
return
}
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
// 防止重复执行动画
if (animationInProgressRef.current) {
options?.onComplete?.()
resolve()
return
}
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
// 检查碎片是否已解锁
if (isPuzzleUnlocked(residence, pieceId)) {
options?.onComplete?.()
resolve()
return
}
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 标记动画正在进行
animationInProgressRef.current = true
animationCompletionRef.current = resolve
animationCompletionCallbackRef.current = options?.onComplete ?? null
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
// 开始动画流程:禁用所有操作
setIsAnimationPlaying(true)
setPuzzlePieceId(pieceId)
setShowPuzzlePiece(true)
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
// 等待初始出现动画(旋转+放大)完成
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
// 等待发光动画显示一段时间,让用户看到碎片
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
// 等待元素准备就绪并执行飞行动画
await waitForElementsAndAnimate(pieceId)
})
})
})
}, 0)
})
},
[
+9 -1
View File
@@ -4,6 +4,8 @@ import { createPortal } from 'react-dom'
import { useScale } from '@/contexts/ScaleContext'
export interface ScaledPortalProps {
/** 是否显示 */
show?: boolean
/** 是否启用(不启用则直接渲染 children) */
enabled?: boolean
/** z-index 层级(建议 > ExhibitOverlay 的 9999) */
@@ -17,7 +19,12 @@ export interface ScaledPortalProps {
* - 使用 Portal 渲染到 document.body,避免被其他 Portal/遮罩层挡住
* - 使用 useScale 的 offset/scale 做视觉对齐(与 ExhibitOverlay 一致)
*/
const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortalProps) => {
const ScaledPortal = ({
enabled = true,
zIndex = 10050,
show = true,
children,
}: ScaledPortalProps) => {
const [mounted, setMounted] = useState(false)
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
@@ -25,6 +32,7 @@ const ScaledPortal = ({ enabled = true, zIndex = 10050, children }: ScaledPortal
setMounted(true)
}, [])
if (!show) return null
if (!enabled) return <>{children}</>
if (!mounted) return null
+1 -1
View File
@@ -42,7 +42,7 @@ 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">{text}</div>
<div className="text-white text-5xl font-medium tracking-widest leading-18">{text}</div>
</div>
{/* 继续按钮 */}
@@ -29,7 +29,7 @@ export interface SuccessAlertProps {
* 使用 components/正确版 作为背景
*/
const SuccessAlert = ({
title = '恭喜你, 选择正确!',
title = '恭喜你,选择正确!',
content,
visible = true,
audioSrc,
@@ -108,16 +108,12 @@ const SuccessAlert = ({
if (audioSrc && currentSrc === audioSrc) {
stop()
}
// 只调用 onContinue,它会处理关闭逻辑
if (onContinue) {
onContinue()
} else if (onClose) {
onClose()
}
onContinue?.()
onClose?.()
}
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div className="fixed inset-0 z-10000 flex items-center justify-center">
{/* 蒙版背景 */}
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
+3 -3
View File
@@ -55,7 +55,7 @@ const DragQuizPanel = ({
'inline-flex items-center justify-center min-w-[140px] h-[56px] px-6 mx-3',
'rounded-xl border-2 transition-all duration-300 shadow-[0_4px_16px_rgba(0,0,0,0.2)]',
filledValue
? 'border-[#f0be75] bg-gradient-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm'
? 'border-[#f0be75] bg-linear-to-br from-[#3a3020] to-[#2a2520] backdrop-blur-sm'
: 'border-[#d4c5a9]/60 bg-white/10 backdrop-blur-sm hover:border-[#d4c5a9]'
)}
>
@@ -111,11 +111,11 @@ const DragQuizPanel = ({
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
isUsed
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
: 'border-[#d4c5a9] bg-gradient-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_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]'
)}
>
{!isUsed && (
<div className="absolute inset-0 rounded-[32px] bg-gradient-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)]">
{option.label}
+83 -83
View File
@@ -22,57 +22,55 @@
### 使用示例
```tsx
import { SelectFillQuizPanel } from "@/components";
import { SelectFillQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<SelectFillQuizPanel
visible={showQuiz}
titlePrefix="【填空】毛泽东主席认为,到了"
titleSuffix="年夏天,在理论上,而且在某种程度的行动上,他已成为一个马克思主义者。"
blanks={[
{ id: "year", prefix: "____" },
]}
blanks={[{ id: 'year', prefix: '____' }]}
options={[
{ value: "1919", label: "1919" },
{ value: "1920", label: "1920" },
{ value: "1921", label: "1921" },
{ value: '1919', label: '1919' },
{ value: '1920', label: '1920' },
{ value: '1921', label: '1921' },
]}
correctAnswers={{ year: "1920" }}
correctAnswers={{ year: '1920' }}
errorMessage="回想一下 1920 年 5 月到 7 月在上海居住的这段经历~"
onSubmit={(results, isCorrect) => {
console.log("选择结果:", results, "是否正确:", isCorrect);
console.log('选择结果:', results, '是否正确:', isCorrect)
if (isCorrect) {
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
submitText="确定"
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| titlePrefix | string | - | 整体题干前缀文本(可选) |
| titleSuffix | string | - | 整体题干后缀文本(可选) |
| blanks | SelectFillBlank[] | - | 每个填空位的配置(必填,包含 id / prefix / suffix) |
| options | { value: string; label: string }[] | - | 候选项列表(必填) |
| correctAnswers | Record<string, string> | - | 正确答案映射(可选,不传则不做校验,直接判定为正确) |
| errorMessage | string | `"还不太对,再仔细对照题干和提示想一想吧~"` | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (results: SelectFillResult[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
| submitText | string | "提交" | 提交按钮文本 |
| 属性 | 类型 | 默认值 | 说明 |
| -------------- | --------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------- |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| titlePrefix | string | - | 整体题干前缀文本(可选) |
| titleSuffix | string | - | 整体题干后缀文本(可选) |
| blanks | SelectFillBlank[] | - | 每个填空位的配置(必填,包含 id / prefix / suffix) |
| options | { value: string; label: string }[] | - | 候选项列表(必填) |
| correctAnswers | Record<string, string> | - | 正确答案映射(可选,不传则不做校验,直接判定为正确) |
| errorMessage | string | `"还不太对,再仔细对照题干和提示想一想吧~"` | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (results: SelectFillResult[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
| submitText | string | "提交" | 提交按钮文本 |
### 行为说明
@@ -98,53 +96,53 @@ function MyComponent() {
### 使用方法
```tsx
import { SingleChoiceQuizPanel } from "@/components";
import { SingleChoiceQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<SingleChoiceQuizPanel
visible={showQuiz}
title="这是一道单选题:以下哪个选项是正确的?"
options={[
{ value: "A", label: "选项A" },
{ value: "B", label: "选项B" },
{ value: "C", label: "选项C" },
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
]}
correctAnswer="B"
errorMessage="再想一想,注意题干中的时间和提示信息哦~"
onSubmit={(value, isCorrect) => {
console.log("用户选择:", value);
console.log("是否正确:", isCorrect);
console.log('用户选择:', value)
console.log('是否正确:', isCorrect)
if (isCorrect) {
// 选择正确,可以关闭面板或进入下一步
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
| options | SingleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string | - | 正确答案(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
| 属性 | 类型 | 默认值 | 说明 |
| ------------- | ------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 |
| ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
| options | SingleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string | - | 正确答案(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| onSubmit | (value: string, isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
### 行为说明
@@ -168,61 +166,61 @@ function MyComponent() {
### 使用方法
```tsx
import { MultipleChoiceQuizPanel } from "@/components";
import { MultipleChoiceQuizPanel } from '@/components'
function MyComponent() {
const [showQuiz, setShowQuiz] = useState(false);
const [showQuiz, setShowQuiz] = useState(false)
return (
<MultipleChoiceQuizPanel
visible={showQuiz}
title="这是一道多选题:以下哪些选项是正确的?(可多选)"
options={[
{ value: "A", label: "选项A" },
{ value: "B", label: "选项B" },
{ value: "C", label: "选项C" },
{ value: "D", label: "选项D" },
{ value: 'A', label: '选项A' },
{ value: 'B', label: '选项B' },
{ value: 'C', label: '选项C' },
{ value: 'D', label: '选项D' },
]}
correctAnswer={["A", "B"]}
correctAnswer={['A', 'B']}
errorMessage="很抱歉,你选错了!请你再思考一下吧!"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="请至少选择 1 项才能提交"
submitText="确认提交"
onSubmit={(values, isCorrect) => {
console.log("用户选择:", values);
console.log("是否正确:", isCorrect);
console.log('用户选择:', values)
console.log('是否正确:', isCorrect)
if (isCorrect) {
setShowQuiz(false);
setShowQuiz(false)
}
}}
onError={(error) => {
console.log("错误信息:", error);
console.log('错误信息:', error)
}}
/>
);
)
}
```
### API
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
| options | MultipleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string[] | - | 正确答案数组(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 |
| onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
| minSelections | number | - | 最小选择数量 |
| maxSelections | number | - | 最大选择数量 |
| submitText | string | "提交" | 提交按钮文本 |
| 属性 | 类型 | 默认值 | 说明 |
| ------------------------- | ---------------------------------------------- | -------------------------------------- | ------------------------------------ |
| 属性 | 类型 | 默认值 | 说明 |
| ------ | ------ | -------- | ------ |
| visible | boolean | - | 是否可见(必填) |
| zIndex | number | 10000 | z-index 层级 |
| title | string | - | 题目(必填) |
| options | MultipleChoiceOption[] | - | 选项列表(必填) |
| correctAnswer | string[] | - | 正确答案数组(可选,用于验证) |
| errorMessage | string | "很抱歉,你选错了!请你再思考一下吧!" | 自定义错误提示信息(答案错误时显示) |
| minSelectionsErrorMessage | string | `请至少选择 ${minSelections} 项` | 自定义最小选择数量不足时的提示信息 |
| onSubmit | (values: string[], isCorrect: boolean) => void | - | 提交回调 |
| onError | (error: string) => void | - | 错误回调 |
| disabled | boolean | false | 是否禁用 |
| minSelections | number | - | 最小选择数量 |
| maxSelections | number | - | 最大选择数量 |
| submitText | string | "提交" | 提交按钮文本 |
### 行为说明
@@ -237,6 +235,7 @@ function MyComponent() {
### 从旧版 QuizPanel 迁移
**旧版用法:**
```tsx
<QuizPanel
visible={showQuiz}
@@ -245,17 +244,18 @@ function MyComponent() {
value={quizAnswer}
error={quizError}
onChange={(val) => {
setQuizAnswer(val);
if (val === "B") {
setQuizError("");
setQuizAnswer(val)
if (val === 'B') {
setQuizError('')
} else {
setQuizError("很抱歉,你选错了!");
setQuizError('很抱歉,你选错了!')
}
}}
/>
```
**新版用法:**
```tsx
<SingleChoiceQuizPanel
visible={showQuiz}
@@ -159,7 +159,6 @@ const SingleChoiceQuizPanel = ({
<SuccessAlert
{...successAlertProps}
visible={successAlertVisible}
onContinue={handleSuccessContinue}
onClose={handleSuccessContinue}
/>
)}
+7 -5
View File
@@ -8,9 +8,9 @@ import { showHint } from '@/components/hint'
const formatCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = `${now.getMonth() + 1}`.padStart(2, '0')
const day = `${now.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
const month = now.getMonth() + 1
const day = now.getDate()
return `${year}年${month}月${day}日`
}
export type LetterFieldType = 'text' | 'input' | 'select'
@@ -305,12 +305,14 @@ const LetterTemplate = ({
}
return (
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]">
<div className="absolute right-[190px] bottom-[120px] z-100 flex flex-col items-end gap-2 text-[#4e402e]">
<style>{`.signature-name-input::placeholder { color: #999999; font-size: 26px; }`}</style>
<input
name="signature-name"
className={`h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[120px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
className={`signature-name-input h-[36px] bg-transparent border-b-2 border-dashed text-center outline-none w-[240px] ${signatureError ? 'border-red-500' : 'border-[#4e402e]'}`}
style={{ fontSize: '26px' }}
autoComplete="off"
placeholder="请郑重签下你的姓名"
value={signatureName}
maxLength={12}
onChange={(e) => handleSignatureNameChange(e.target.value)}
@@ -82,7 +82,7 @@ const LetterToHuangyanpei = ({
onClose={onClose}
onSubmit={onSubmit}
onAnimationEnd={onAnimationEnd}
title="寄给黄炎培的信"
title="写给黄炎培先生"
letterContent={letterContent}
fields={fields}
contentClassName="!text-[25px]"
+2 -2
View File
@@ -63,7 +63,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '横眉冷对',
readOnlyFallback: '横眉冷对',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
4: {
@@ -73,7 +73,7 @@ const fields: Record<number, LetterFieldConfig> = {
correctValue: '孺子牛',
readOnlyFallback: '孺子牛',
hints: {
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
error: '“横眉冷对千夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
},
},
5: {
@@ -19,7 +19,7 @@ const commonSelectField: LetterFieldConfig = {
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
options: [
{ value: '天下为公', label: '天下为公' },
{ value: '敢为天下先', label: '敢为天下先' },
{ value: '与时俱进', label: '与时俱进' },
{ value: '振兴中华', label: '振兴中华' },
],
}
@@ -27,7 +27,7 @@ const commonSelectField: LetterFieldConfig = {
const letterContent = `敬爱的孙中山先生:
您好!
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在李教授的解读中,我终于明白:您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
@@ -52,11 +52,11 @@ const fields: Record<number, LetterFieldConfig> = {
},
2: {
...commonSelectField,
correctValue: '敢为天下先',
correctValue: '与时俱进',
hints: {
error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。',
},
readOnlyFallback: '敢为天下先',
readOnlyFallback: '与时俱进',
},
3: {
type: 'input',
+4
View File
@@ -21,6 +21,8 @@ export interface NarrationProps {
autoPlayAudio?: boolean
/** 音频分组 id,相同 channel 内互斥,不同 channel 可同时播放;不传则全局互斥 */
audioChannelId?: string
/** 署名/出处,主文本动画结束后显示在右下方 */
attribution?: string
}
/**
@@ -41,6 +43,7 @@ const Narration = ({
animationSpeed = 100,
autoPlayAudio = true,
audioChannelId,
attribution,
}: NarrationProps) => {
const [displayedText, setDisplayedText] = useState('')
const [isAnimating, setIsAnimating] = useState(true)
@@ -146,6 +149,7 @@ const Narration = ({
<div className="text-white text-3xl leading-relaxed space-y-8">
<p className="whitespace-pre-wrap">{displayedText}</p>
{attribution && !isAnimating && <p className="text-right">{attribution}</p>}
</div>
{/* 关闭按钮 - 只在动画完成后显示 */}
-253
View File
@@ -1,253 +0,0 @@
import { useMemo, useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { usePuzzleStore } from '@/store/puzzleStore'
import { Image } from '@/components'
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
/**
* 六边形进度条组件
* 根据进度值显示不同颜色:100%绿色,50%-99%橙色,0%-49%灰色
*/
interface HexagonProgressBarProps {
progress: number // 0-100
}
const HexagonProgressBar = ({ progress }: HexagonProgressBarProps) => {
const progressPercent = Math.min(100, Math.max(0, progress))
const progressText =
progressPercent.toFixed(2) === '100.00' ? '进度100%' : `进度${Math.round(progressPercent)}%`
// 根据进度值确定填充颜色
const getProgressColor = () => {
if (progressPercent >= 100) {
return '#90B86B' // 浅橄榄绿色(100%完成)
} else if (progressPercent >= 50) {
return '#EEB362' // 橙色(50%-99%)
} else {
return '#E0E0E0' // 浅灰色(0%-49%)
}
}
const fillColor = getProgressColor()
// 六边形路径,细长的水平六边形,左右两端是尖角
// 正确的六边形路径:从左顶点开始,顺时针绘制
// 左顶点(0%, 50%) -> 左上角(3%, 0%) -> 右上角(97%, 0%) -> 右顶点(100%, 50%) -> 右下角(97%, 100%) -> 左下角(3%, 100%)
// 外层路径(包含边框)
const outerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
// 内层路径(边框内部)
const innerHexagonPath = 'polygon(0% 50%, 3% 0%, 97% 0%, 100% 50%, 97% 100%, 3% 100%)'
const borderWidth = 4 // 增加边框宽度从2px到3px
return (
<div className="relative" style={{ width: '260px', height: '50px' }}>
{/* 外层:白色边框(使用 clip-path 裁剪形状) */}
<div
className="absolute inset-0"
style={{
clipPath: outerHexagonPath,
backgroundColor: 'white',
}}
/>
{/* 内容容器(边框内部区域) */}
<div
className="absolute"
style={{
left: `${borderWidth}px`,
top: `${borderWidth}px`,
right: `${borderWidth}px`,
bottom: `${borderWidth}px`,
clipPath: innerHexagonPath,
}}
>
{/* 背景层:深灰色背景(未填充部分) */}
<div
className="absolute inset-0"
style={{
backgroundColor: '#757575',
}}
/>
{/* 填充层(根据进度百分比从左侧填充) */}
{progressPercent > 0 && (
<div
className="absolute left-0 top-0 bottom-0 overflow-hidden"
style={{
width: `${progressPercent}%`,
}}
>
<div
className="absolute inset-0"
style={{
backgroundColor: fillColor,
transition: 'width 0.3s ease, background-color 0.3s ease',
}}
/>
</div>
)}
</div>
{/* 文字(白色,居中,始终显示在最上层,带阴影效果) */}
<div
className="absolute inset-0 flex items-center justify-center text-white text-2xl font-bold pointer-events-none"
style={{
textShadow: '0 2px 4px rgba(0,0,0,0.6), 0 1px 2px rgba(0,0,0,0.8)',
zIndex: 10,
}}
>
{progressText}
</div>
</div>
)
}
export interface MenuPuzzleCenterProps {
/** 是否可见 */
visible: boolean
/** 关闭回调 */
onClose: () => void
/** 选择某个故居后的回调(进入对应的碎片系统) */
onSelectResidence: (residence: string) => void
/** 是否允许从管理中心进入具体拼图 */
allowPuzzleEntry?: boolean
}
const MenuPuzzleCenter = ({
visible,
onClose,
onSelectResidence,
allowPuzzleEntry = true,
}: MenuPuzzleCenterProps) => {
const { getPuzzleCollectProgress } = usePuzzleStore()
const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
const residenceProgress = useMemo(
() =>
PUZZLE_RESIDENCES.map((item) => ({
...item,
// 只有已开放拼图的故居才计算收集进度
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
})),
[getPuzzleCollectProgress]
)
if (!visible) return null
return (
<div
className="fixed inset-0 z-90 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.8)' }}
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
>
<div
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: getBackgroundImage('puzzle/home/底图') }}
>
{/* 箭头指示底图 */}
<img
className="absolute top-84 left-55"
src={getStaticImage('puzzle/home/箭头指引')}
alt=""
/>
{/* 关闭按钮 */}
<Image
className="absolute right-8 top-8 z-10"
src={getStaticImage('components/关闭')}
alt="关闭"
button
onClick={onClose}
/>
{/* 故居列表:按照设计图进行绝对定位 */}
<div className="absolute inset-0">
{residenceProgress.map(
({
residence,
displayName,
left,
top,
image,
hasPuzzle,
progress,
hasMap,
mapImage,
}) => (
<div
key={residence}
className={`absolute -translate-x-1/2 -translate-y-1/2`}
style={{ left, top }}
>
{/* 故居图片(资源在 puzzle/home 下,可根据实际名称调整) */}
<Image src={getStaticImage(`puzzle/home/${image}`)} alt={displayName} />
{/* 进度展示,仅对已开放拼图的故居显示 */}
{hasPuzzle && (
<div className="mb-4 flex justify-center">
<HexagonProgressBar progress={progress} />
</div>
)}
{/* 图标 */}
<div className="flex justify-center gap-6">
{allowPuzzleEntry && (
<Image
src={getStaticImage(`puzzle/home/拼图`)}
alt="拼图"
button
onClick={(e) => {
if (!hasPuzzle) return
e.stopPropagation()
onSelectResidence(residence)
}}
/>
)}
<Image
src={getStaticImage(`puzzle/home/追光入口`)}
alt="导图"
button
onClick={(e) => {
e.stopPropagation()
if (!hasMap || !mapImage) return
setActiveMapImage(mapImage)
}}
/>
</div>
</div>
)
)}
</div>
{/* 导图弹窗 */}
{activeMapImage && (
<div
className="fixed inset-0 z-110 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.75)' }}
onClick={(e) => {
if (e.target === e.currentTarget) {
setActiveMapImage(null)
}
}}
>
<div className="relative w-full h-full">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<Image
className="absolute -right-4 -top-8 z-10"
src={getStaticImage('components/close')}
alt="关闭"
button
onClick={() => setActiveMapImage(null)}
/>
<img src={getStaticImage(`puzzle/home/map/${activeMapImage}`)} alt="导图" />
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default MenuPuzzleCenter
+9 -9
View File
@@ -12,14 +12,14 @@ import { Image } from '@/components'
*/
const getResidenceDisplayName = (residence: string): string => {
const nameMap: Record<string, string> = {
chenyun: '陈云',
huangyanpei: '黄炎培',
luxun: '鲁迅',
sunzhongshan: '孙中山',
maozedong: '毛泽东',
zhangleping: '张乐平',
nieer: '聂耳',
rongdesheng: '荣德生',
chenyun: '陈云故居',
huangyanpei: '黄炎培故居',
luxun: '鲁迅故居',
sunzhongshan: '孙中山故居',
maozedong: '毛泽东旧居',
zhangleping: '张乐平故居',
nieer: '聂耳旧居',
rongdesheng: '荣德生旧居',
}
return nameMap[residence] || residence
@@ -299,7 +299,7 @@ const PuzzleSystem = ({
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
)}
>
{getResidenceDisplayName(residence)}故居碎片
{getResidenceDisplayName(residence)}碎片
</span>
<img
className="absolute -top-10 left-0 -z-10"
+3 -2
View File
@@ -86,7 +86,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}
setContentWidth(calculatedWidth)
}, [fitContent, maxWidth, minWidth, options, placeholder])
}, [fitContent, maxWidth, minWidth, options, placeholder, fontSize])
const updateDropdownPosition = useCallback(() => {
if (!triggerRef.current) return
@@ -240,7 +240,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
}}
className={cn(
'inline-flex h-[36px] items-center justify-center border-b-2 border-dashed bg-transparent align-baseline outline-none',
'mx-2 cursor-pointer whitespace-nowrap px-2 text-center text-[#4e402e]',
'mx-2 cursor-pointer whitespace-nowrap px-2 text-center',
selectedOption ? 'text-[#4e402e]' : 'text-[#999999]',
error ? 'border-red-500' : 'border-[#4e402e]',
disabled && 'cursor-not-allowed opacity-50',
className
+22
View File
@@ -3,6 +3,15 @@ import { PUZZLE_RESIDENCES } from './puzzleResidences'
export type PuzzleProgress = Record<string, Set<number>>
const BUILD_UNLOCK_ALL = import.meta.env.VITE_PROGRESS_UNLOCK_ALL === 'true'
const UNLOCK_ALL_STORAGE_KEY = 'debug:progression-unlock-all'
const isUnlockAllEnabled = () => {
if (!BUILD_UNLOCK_ALL) return false
if (typeof window === 'undefined') return true
return window.sessionStorage.getItem(UNLOCK_ALL_STORAGE_KEY) !== 'false'
}
const getResidenceIndex = (residence: string) =>
PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
@@ -24,10 +33,13 @@ export const isResidenceStarted = (residence: string, puzzles: PuzzleProgress) =
}
export const isResidenceComplete = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
return arePiecesUnlocked(residence, puzzles, getConfiguredPieceIds(residence))
}
export const isResidenceUnlocked = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const index = getResidenceIndex(residence)
if (index === 0) return true
if (index < 0) return false
@@ -50,6 +62,8 @@ export const isMenuModuleStarted = (moduleName: string, puzzles: PuzzleProgress)
}
export const isMenuModuleComplete = (moduleName: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) return true
const residences = PUZZLE_RESIDENCES.filter((item) => item.moduleName === moduleName)
if (residences.length === 0) return false
return residences.every((item) => isResidenceComplete(item.residence, puzzles))
@@ -65,6 +79,14 @@ const getPieceIdsByRoute = (residence: string, routePrefix: string) => {
}
export const getResidenceStageUnlocks = (residence: string, puzzles: PuzzleProgress) => {
if (isUnlockAllEnabled()) {
return {
dialogueUnlocked: true,
reflectionUnlocked: true,
internalizationUnlocked: true,
}
}
const residenceUnlocked = isResidenceUnlocked(residence, puzzles)
return {
+24 -26
View File
@@ -80,7 +80,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片11
11: {
title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿',
title: '陈云起草的关于发展国民 经济“一五”计划报告的手稿',
detailImage: 'chenyun/details/11',
route: 'place8',
},
@@ -148,7 +148,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片6
6: {
title: '中华职业教育 和中华职业学校',
title: '职业教育 和中华职业学校',
detailImage: 'huangyanpei/details/6',
route: 'place4',
},
@@ -184,7 +184,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片12
12: {
title: '窑洞对话',
title: '窑洞对',
detailImage: 'huangyanpei/details/12',
route: 'place5',
},
@@ -196,7 +196,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
},
// 碎片14
14: {
title: '黄炎培对浦东中学新生的教导',
title: '黄炎培对浦东中学 新生的教导',
detailImage: 'huangyanpei/details/14',
route: 'reflection2',
},
@@ -260,7 +260,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
10: {
title: '《中国国民党第一次 全国代表大会宣言》',
title: '中国国民党第一次 全国代表大会宣言',
detailImage: 'sunzhongshan/details/10',
route: 'place3',
},
@@ -399,22 +399,22 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place2',
},
5: {
title: '中共三大会议召开',
title: '中共三大召开',
detailImage: 'maozedong/details/5',
route: 'place3',
},
6: {
title: '《孙中山手书名单》',
title: '孙中山手书名单',
detailImage: 'maozedong/details/6',
route: 'place3',
},
7: {
title: '《孙文越飞联合宣言》',
title: '孙文越飞联合宣言',
detailImage: 'maozedong/details/7',
route: 'place3',
},
8: {
title: '毛泽东签发的两份中共四大通知',
title: '毛泽东签发的两份 中共四大通知',
detailImage: 'maozedong/details/8',
route: 'place4',
},
@@ -424,7 +424,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
10: {
title: '毛泽东中国社会分析',
title: '中国社会各阶级的分析',
detailImage: 'maozedong/details/10',
route: 'place4',
},
@@ -501,34 +501,34 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place4',
},
9: {
title: '刘良模指挥数千人齐唱',
title: '国歌选定',
detailImage: 'nieer/details/9',
route: 'place4',
route: 'place5',
},
10: {
title: '国歌选定',
title: '国歌入宪',
detailImage: 'nieer/details/10',
route: 'place5',
},
11: {
title: '国歌入宪',
title: '国歌立法',
detailImage: 'nieer/details/11',
route: 'place5',
},
12: {
title: '国歌立法',
title: '新中国奏响国歌的场景(上)',
detailImage: 'nieer/details/12',
route: 'place5',
route: 'place6',
},
13: {
title: '新中国奏响国歌的场景(上)',
title: '新中国奏响国歌的场景(下)',
detailImage: 'nieer/details/13',
route: 'place6',
},
14: {
title: '新中国奏响国歌的场景(下)',
title: '唱国家一定要站起来',
detailImage: 'nieer/details/14',
route: 'place6',
route: 'reflection',
},
15: {
title: '聂耳「人民音乐家」',
@@ -578,7 +578,7 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place3',
},
8: {
title: '三毛新生(《向日葵》张乐平,1950-1965)',
title: '三毛新生',
detailImage: 'zhangleping/details/8',
route: 'place3',
},
@@ -608,17 +608,17 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'place5',
},
14: {
title: '画自己',
title: '创作心路',
detailImage: 'zhangleping/details/14',
route: 'place5',
route: 'reflection',
},
15: {
title: '平民立场',
title: '艺术来源于生活',
detailImage: 'zhangleping/details/15',
route: 'internalization',
},
16: {
title: '笔耕不辍',
title: '核心创作理念',
detailImage: 'zhangleping/details/16',
route: 'internalization',
},
@@ -705,8 +705,6 @@ export const puzzleDataConfig: Record<string, Record<number, PuzzlePieceData>> =
route: 'internalization',
},
},
// 后续可以添加其他故居的数据
// sunzhongshan: { ... },
}
/**
+51
View File
@@ -0,0 +1,51 @@
const STORAGE_KEY = 'debug:progression-unlock-all'
const BADGE_ID = 'progression-debug-badge'
const isUnlockAllEnabled = () => {
return window.sessionStorage.getItem(STORAGE_KEY) !== 'false'
}
const getBadgeText = () => {
return isUnlockAllEnabled() ? '测试包:全解锁' : '测试包:真实进度'
}
const updateBadge = () => {
let badge = document.getElementById(BADGE_ID)
if (!badge) {
badge = document.createElement('div')
badge.id = BADGE_ID
badge.style.position = 'fixed'
badge.style.right = '12px'
badge.style.top = '12px'
badge.style.zIndex = '99999'
badge.style.padding = '8px 10px'
badge.style.borderRadius = '8px'
badge.style.background = 'rgba(0, 0, 0, 0.72)'
badge.style.color = '#fff'
badge.style.fontSize = '12px'
badge.style.lineHeight = '1.5'
badge.style.pointerEvents = 'none'
badge.style.whiteSpace = 'pre-line'
document.body.appendChild(badge)
}
badge.textContent = `${getBadgeText()}\nCtrl+Shift+U 切换`
}
const toggleUnlockAll = () => {
const nextValue = isUnlockAllEnabled() ? 'false' : 'true'
window.sessionStorage.setItem(STORAGE_KEY, nextValue)
updateBadge()
window.location.reload()
}
export const setupProgressionDebug = () => {
updateBadge()
window.addEventListener('keydown', (event) => {
if (!event.ctrlKey || !event.shiftKey || event.key.toLowerCase() !== 'u') return
event.preventDefault()
toggleUnlockAll()
})
}
+118 -122
View File
@@ -17,16 +17,16 @@
### 1. 简单点击
```tsx
import { useTouch } from "@/hooks";
import { useTouch } from '@/hooks'
function MyComponent() {
const { handlers } = useTouch({
onTap: () => {
console.log("点击了");
console.log('点击了')
},
});
})
return <div {...handlers}>点击我</div>;
return <div {...handlers}>点击我</div>
}
```
@@ -34,13 +34,13 @@ function MyComponent() {
```tsx
const { handlers } = useTouch({
onSwipeLeft: () => console.log("向左滑动"),
onSwipeRight: () => console.log("向右滑动"),
onSwipeUp: () => console.log("向上滑动"),
onSwipeDown: () => console.log("向下滑动"),
});
onSwipeLeft: () => console.log('向左滑动'),
onSwipeRight: () => console.log('向右滑动'),
onSwipeUp: () => console.log('向上滑动'),
onSwipeDown: () => console.log('向下滑动'),
})
return <div {...handlers}>滑动区域</div>;
return <div {...handlers}>滑动区域</div>
```
### 3. 双击
@@ -48,11 +48,11 @@ return <div {...handlers}>滑动区域</div>;
```tsx
const { handlers } = useTouch({
onDoubleTap: () => {
console.log("双击触发");
console.log('双击触发')
},
});
})
return <div {...handlers}>双击我</div>;
return <div {...handlers}>双击我</div>
```
### 4. 长按
@@ -60,12 +60,12 @@ return <div {...handlers}>双击我</div>;
```tsx
const { handlers } = useTouch({
onLongPress: () => {
console.log("长按触发");
console.log('长按触发')
},
longPressDelay: 500, // 500ms 触发长按
});
})
return <div {...handlers}>长按我</div>;
return <div {...handlers}>长按我</div>
```
## 高级用法
@@ -75,11 +75,11 @@ return <div {...handlers}>长按我</div>;
```tsx
const { handlers, touchData, isTouching } = useTouch({
onTouchMove: (data) => {
console.log("移动距离:", data.deltaX, data.deltaY);
console.log("移动速度:", data.velocity);
console.log("移动方向:", data.direction);
console.log('移动距离:', data.deltaX, data.deltaY)
console.log('移动速度:', data.velocity)
console.log('移动方向:', data.direction)
},
});
})
return (
<div {...handlers}>
@@ -89,20 +89,20 @@ return (
</div>
)}
</div>
);
)
```
### 图片查看器示例
```tsx
function ImageViewer() {
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const { handlers } = useTouch({
onDoubleTap: () => {
// 双击放大/还原
setScale(scale === 1 ? 2 : 1);
setScale(scale === 1 ? 2 : 1)
},
onSwipe: (data) => {
// 滑动移动图片
@@ -110,23 +110,23 @@ function ImageViewer() {
setPosition((prev) => ({
x: prev.x + data.deltaX,
y: prev.y + data.deltaY,
}));
}))
}
},
swipeThreshold: 5,
});
})
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<div {...handlers} style={{ overflow: 'hidden' }}>
<img
src="image.jpg"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
transition: "transform 0.3s",
transition: 'transform 0.3s',
}}
/>
</div>
);
)
}
```
@@ -134,36 +134,36 @@ function ImageViewer() {
```tsx
function SwipeableCard() {
const [offset, setOffset] = useState(0);
const [offset, setOffset] = useState(0)
const { handlers } = useTouch({
onTouchMove: (data) => {
if (data.direction === "left" || data.direction === "right") {
setOffset(data.deltaX);
if (data.direction === 'left' || data.direction === 'right') {
setOffset(data.deltaX)
}
},
onTouchEnd: (data) => {
if (Math.abs(data.deltaX) > 100) {
// 滑动超过 100px,执行删除
onDelete();
onDelete()
} else {
// 弹回原位
setOffset(0);
setOffset(0)
}
},
});
})
return (
<div
{...handlers}
style={{
transform: `translateX(${offset}px)`,
transition: offset === 0 ? "transform 0.3s" : "none",
transition: offset === 0 ? 'transform 0.3s' : 'none',
}}
>
<div>卡片内容</div>
</div>
);
)
}
```
@@ -171,42 +171,38 @@ function SwipeableCard() {
```tsx
function PullToRefresh() {
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const { handlers } = useTouch({
onTouchMove: (data) => {
// 只在向下滑动且在顶部时触发
if (data.direction === "down" && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80));
if (data.direction === 'down' && window.scrollY === 0) {
setPullDistance(Math.min(data.deltaY, 80))
}
},
onTouchEnd: (data) => {
if (pullDistance > 50) {
setIsRefreshing(true);
setIsRefreshing(true)
// 执行刷新逻辑
refresh().then(() => {
setIsRefreshing(false);
setPullDistance(0);
});
setIsRefreshing(false)
setPullDistance(0)
})
} else {
setPullDistance(0);
setPullDistance(0)
}
},
});
})
return (
<div {...handlers}>
<div style={{ height: pullDistance, transition: "height 0.3s" }}>
{isRefreshing
? "刷新中..."
: pullDistance > 50
? "松开刷新"
: "下拉刷新"}
<div style={{ height: pullDistance, transition: 'height 0.3s' }}>
{isRefreshing ? '刷新中...' : pullDistance > 50 ? '松开刷新' : '下拉刷新'}
</div>
<div>内容区域</div>
</div>
);
)
}
```
@@ -214,34 +210,34 @@ function PullToRefresh() {
```tsx
function Carousel({ images }: { images: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0)
const { handlers } = useTouch({
onSwipeLeft: () => {
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1));
setCurrentIndex((prev) => Math.min(prev + 1, images.length - 1))
},
onSwipeRight: () => {
setCurrentIndex((prev) => Math.max(prev - 1, 0));
setCurrentIndex((prev) => Math.max(prev - 1, 0))
},
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
});
})
return (
<div {...handlers} style={{ overflow: "hidden" }}>
<div {...handlers} style={{ overflow: 'hidden' }}>
<div
style={{
display: "flex",
display: 'flex',
transform: `translateX(-${currentIndex * 100}%)`,
transition: "transform 0.3s",
transition: 'transform 0.3s',
}}
>
{images.map((img, i) => (
<img key={i} src={img} style={{ width: "100%", flexShrink: 0 }} />
<img key={i} src={img} style={{ width: '100%', flexShrink: 0 }} />
))}
</div>
</div>
);
)
}
```
@@ -250,34 +246,34 @@ function Carousel({ images }: { images: string[] }) {
```typescript
interface UseTouchOptions {
// 基础配置
enabled?: boolean; // 是否启用,默认 true
preventDefault?: boolean; // 是否阻止默认行为,默认 false
stopPropagation?: boolean; // 是否阻止事件冒泡,默认 false
enabled?: boolean // 是否启用,默认 true
preventDefault?: boolean // 是否阻止默认行为,默认 false
stopPropagation?: boolean // 是否阻止事件冒泡,默认 false
// 滑动配置
swipeThreshold?: number; // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number; // 滑动速度阈值(px/ms),默认 0.5
swipeThreshold?: number // 滑动阈值(px),默认 10
swipeVelocityThreshold?: number // 滑动速度阈值(px/ms),默认 0.5
// 长按配置
longPressDelay?: number; // 长按延迟(ms),默认 500
longPressMoveThreshold?: number; // 长按允许移动距离(px),默认 10
longPressDelay?: number // 长按延迟(ms),默认 500
longPressMoveThreshold?: number // 长按允许移动距离(px),默认 10
// 双击配置
doubleTapDelay?: number; // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number; // 双击允许移动距离(px),默认 10
doubleTapDelay?: number // 双击间隔(ms),默认 300
doubleTapMoveThreshold?: number // 双击允许移动距离(px),默认 10
// 事件回调
onTouchStart?: (data: TouchEventData) => void;
onTouchMove?: (data: TouchEventData) => void;
onTouchEnd?: (data: TouchEventData) => void;
onTap?: (data: TouchEventData) => void;
onDoubleTap?: (data: TouchEventData) => void;
onLongPress?: (data: TouchEventData) => void;
onSwipe?: (data: TouchEventData) => void;
onSwipeLeft?: (data: TouchEventData) => void;
onSwipeRight?: (data: TouchEventData) => void;
onSwipeUp?: (data: TouchEventData) => void;
onSwipeDown?: (data: TouchEventData) => void;
onTouchStart?: (data: TouchEventData) => void
onTouchMove?: (data: TouchEventData) => void
onTouchEnd?: (data: TouchEventData) => void
onTap?: (data: TouchEventData) => void
onDoubleTap?: (data: TouchEventData) => void
onLongPress?: (data: TouchEventData) => void
onSwipe?: (data: TouchEventData) => void
onSwipeLeft?: (data: TouchEventData) => void
onSwipeRight?: (data: TouchEventData) => void
onSwipeUp?: (data: TouchEventData) => void
onSwipeDown?: (data: TouchEventData) => void
}
```
@@ -287,14 +283,14 @@ interface UseTouchOptions {
```typescript
interface TouchEventData {
startPoint: TouchPoint; // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint; // 当前位置 { x, y, timestamp }
deltaX: number; // X 轴移动距离
deltaY: number; // Y 轴移动距离
distance: number; // 移动的总距离
velocity: number; // 移动速度(像素/毫秒)
direction: SwipeDirection; // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent; // 原始事件
startPoint: TouchPoint // 起始位置 { x, y, timestamp }
currentPoint: TouchPoint // 当前位置 { x, y, timestamp }
deltaX: number // X 轴移动距离
deltaY: number // Y 轴移动距离
distance: number // 移动的总距离
velocity: number // 移动速度(像素/毫秒)
direction: SwipeDirection // 移动方向: 'left' | 'right' | 'up' | 'down' | null
event: React.TouchEvent | React.MouseEvent // 原始事件
}
```
@@ -302,14 +298,14 @@ interface TouchEventData {
```typescript
interface UseTouchReturn {
isTouching: boolean; // 是否正在触摸
touchData: TouchEventData | null; // 当前触摸数据
isTouching: boolean // 是否正在触摸
touchData: TouchEventData | null // 当前触摸数据
handlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onMouseDown: (e: React.MouseEvent) => void;
};
onTouchStart: (e: React.TouchEvent) => void
onTouchMove: (e: React.TouchEvent) => void
onTouchEnd: (e: React.TouchEvent) => void
onMouseDown: (e: React.MouseEvent) => void
}
}
```
@@ -329,7 +325,7 @@ interface UseTouchReturn {
const { handlers } = useTouch({
onSwipeLeft: handleSwipe,
preventDefault: false, // 不阻止默认行为,允许页面滚动
});
})
```
### 2. 性能优化
@@ -342,9 +338,9 @@ const touchOptions = useMemo(
swipeThreshold: 50,
}),
[handleSwipe]
);
)
const { handlers } = useTouch(touchOptions);
const { handlers } = useTouch(touchOptions)
```
### 3. 条件禁用
@@ -353,31 +349,31 @@ const { handlers } = useTouch(touchOptions);
const { handlers } = useTouch({
enabled: !isLoading, // 加载时禁用触摸
onTap: handleTap,
});
})
```
## 完整示例
```tsx
import { useTouch } from "@/hooks";
import { useState } from "react";
import { useTouch } from '@/hooks'
import { useState } from 'react'
function TouchDemo() {
const [log, setLog] = useState<string[]>([]);
const [log, setLog] = useState<string[]>([])
const addLog = (message: string) => {
setLog((prev) => [...prev, message].slice(-5));
};
setLog((prev) => [...prev, message].slice(-5))
}
const { handlers, isTouching, touchData } = useTouch({
onTap: () => addLog("点击"),
onDoubleTap: () => addLog("双击"),
onLongPress: () => addLog("长按"),
onSwipeLeft: () => addLog("向左滑动"),
onSwipeRight: () => addLog("向右滑动"),
onSwipeUp: () => addLog("向上滑动"),
onSwipeDown: () => addLog("向下滑动"),
});
onTap: () => addLog('点击'),
onDoubleTap: () => addLog('双击'),
onLongPress: () => addLog('长按'),
onSwipeLeft: () => addLog('向左滑动'),
onSwipeRight: () => addLog('向右滑动'),
onSwipeUp: () => addLog('向上滑动'),
onSwipeDown: () => addLog('向下滑动'),
})
return (
<div>
@@ -386,12 +382,12 @@ function TouchDemo() {
style={{
width: 300,
height: 300,
border: "2px solid #ccc",
display: "flex",
alignItems: "center",
justifyContent: "center",
userSelect: "none",
background: isTouching ? "#f0f0f0" : "white",
border: '2px solid #ccc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
background: isTouching ? '#f0f0f0' : 'white',
}}
>
触摸测试区域
@@ -410,7 +406,7 @@ function TouchDemo() {
))}
</div>
</div>
);
)
}
```
+128 -134
View File
@@ -1,244 +1,238 @@
@import "tailwindcss";
@import "./styles/input.css";
@import 'tailwindcss';
@import './styles/input.css';
/* 定义字体 */
@font-face {
font-family: "SourceHanSerifCN";
src: url("/fonts/SourceHanSerifCN-Bold-2.otf") format("opentype");
font-family: 'SourceHanSerifCN';
src: url('/fonts/SourceHanSerifCN-Bold-2.otf') format('opentype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
/* *{ */
/* margin: 0;
/* margin: 0;
padding: 0; */
/* box-sizing: border-box; */
/* box-sizing: border-box; */
/* } */
body{
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #000;
font-family: "SourceHanSerifCN", serif;
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #000;
font-family: 'SourceHanSerifCN', serif;
}
#root{
width: 100%;
height: 100%;
#root {
width: 100%;
height: 100%;
}
button:not([disabled]){
cursor: pointer;
button:not([disabled]) {
cursor: pointer;
}
img, video{
max-width: unset;
img,
video {
max-width: unset;
}
/* 自定义滚动条样式 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(139, 115, 85, 0.6) transparent;
scrollbar-width: thin;
scrollbar-color: rgba(139, 115, 85, 0.6) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
margin: 0;
background: transparent;
margin: 0;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom, rgba(139, 115, 85, 0.8), rgba(107, 93, 71, 0.8));
border-radius: 4px;
border: none;
transition: background 0.3s ease;
background: linear-gradient(to bottom, rgba(139, 115, 85, 0.8), rgba(107, 93, 71, 0.8));
border-radius: 4px;
border: none;
transition: background 0.3s ease;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom, rgba(139, 115, 85, 1), rgba(107, 93, 71, 1));
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.4);
background: linear-gradient(to bottom, rgba(139, 115, 85, 1), rgba(107, 93, 71, 1));
box-shadow: 0 2px 8px rgba(139, 115, 85, 0.4);
}
.custom-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(to bottom, rgba(107, 93, 71, 1), rgba(91, 79, 61, 1));
background: linear-gradient(to bottom, rgba(107, 93, 71, 1), rgba(91, 79, 61, 1));
}
/* 多选题面板滚动条样式 - 金色主题 */
.quiz-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(212, 197, 169, 0.7) transparent;
scrollbar-width: thin;
scrollbar-color: rgba(212, 197, 169, 0.7) transparent;
}
.quiz-scrollbar::-webkit-scrollbar {
width: 10px;
width: 10px;
}
.quiz-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
margin: 8px 0;
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
margin: 8px 0;
}
.quiz-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(to bottom,
rgba(212, 197, 169, 0.8),
rgba(240, 190, 117, 0.8));
border-radius: 5px;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
background: linear-gradient(to bottom, rgba(212, 197, 169, 0.8), rgba(240, 190, 117, 0.8));
border-radius: 5px;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.quiz-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(to bottom,
rgba(240, 190, 117, 0.9),
rgba(212, 197, 169, 0.9));
box-shadow: 0 2px 8px rgba(240, 190, 117, 0.5);
border-color: rgba(240, 190, 117, 0.3);
background: linear-gradient(to bottom, rgba(240, 190, 117, 0.9), rgba(212, 197, 169, 0.9));
box-shadow: 0 2px 8px rgba(240, 190, 117, 0.5);
border-color: rgba(240, 190, 117, 0.3);
}
.quiz-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(to bottom,
rgba(240, 190, 117, 1),
rgba(212, 197, 169, 1));
background: linear-gradient(to bottom, rgba(240, 190, 117, 1), rgba(212, 197, 169, 1));
}
/* 搜索图标动画 */
@keyframes search-pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.1) rotate(5deg);
opacity: 0.8;
}
0%,
100% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.1) rotate(5deg);
opacity: 0.8;
}
}
.animate-search {
animation: search-pulse 2s ease-in-out infinite;
animation: search-pulse 2s ease-in-out infinite;
}
/* 三个点加载动画 */
@keyframes dot-bounce {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.animate-dots > span {
animation: dot-bounce 1.4s ease-in-out infinite both;
animation: dot-bounce 1.4s ease-in-out infinite both;
}
.animate-dots > span:nth-child(1) {
animation-delay: -0.32s;
animation-delay: -0.32s;
}
.animate-dots > span:nth-child(2) {
animation-delay: -0.16s;
animation-delay: -0.16s;
}
.animate-dots > span:nth-child(3) {
animation-delay: 0s;
animation-delay: 0s;
}
/* 页面过渡动画 - 确保初始状态为透明,避免闪烁 */
.page-transition-wrapper > div[data-framer-name] {
opacity: 0;
opacity: 0;
}
/* 鲁迅页面滚动条样式 - 古典文化风格 */
.luxun-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(212, 175, 55, 0.85) rgba(250, 245, 235, 0.08);
scrollbar-width: thin;
scrollbar-color: rgba(212, 175, 55, 0.85) rgba(250, 245, 235, 0.08);
}
.luxun-scrollbar::-webkit-scrollbar {
width: 14px;
width: 14px;
}
.luxun-scrollbar::-webkit-scrollbar-track {
background: linear-gradient(
to bottom,
rgba(250, 245, 235, 0.06),
rgba(240, 235, 225, 0.04)
);
border-radius: 12px;
border: 1.5px solid rgba(139, 115, 71, 0.2);
margin: 6px 2px;
box-shadow:
inset 0 0 8px rgba(139, 115, 71, 0.08),
inset 0 0 2px rgba(255, 255, 255, 0.1);
background: linear-gradient(to bottom, rgba(250, 245, 235, 0.06), rgba(240, 235, 225, 0.04));
border-radius: 12px;
border: 1.5px solid rgba(139, 115, 71, 0.2);
margin: 6px 2px;
box-shadow:
inset 0 0 8px rgba(139, 115, 71, 0.08),
inset 0 0 2px rgba(255, 255, 255, 0.1);
}
.luxun-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(
to bottom,
rgba(240, 190, 117, 0.95),
rgba(212, 175, 55, 0.95),
rgba(184, 134, 11, 0.95)
);
border-radius: 12px;
border: 2px solid rgba(250, 245, 235, 0.4);
box-shadow:
inset 0 1px 3px rgba(255, 255, 255, 0.25),
inset 0 -1px 2px rgba(139, 115, 71, 0.2),
0 2px 6px rgba(139, 115, 71, 0.25),
0 0 10px rgba(212, 175, 55, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
background: linear-gradient(
to bottom,
rgba(240, 190, 117, 0.95),
rgba(212, 175, 55, 0.95),
rgba(184, 134, 11, 0.95)
);
border-radius: 12px;
border: 2px solid rgba(250, 245, 235, 0.4);
box-shadow:
inset 0 1px 3px rgba(255, 255, 255, 0.25),
inset 0 -1px 2px rgba(139, 115, 71, 0.2),
0 2px 6px rgba(139, 115, 71, 0.25),
0 0 10px rgba(212, 175, 55, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
/* 滚动条滑块内部装饰线效果 */
.luxun-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(
to bottom,
rgba(255, 215, 140, 1),
rgba(240, 190, 117, 1),
rgba(212, 175, 55, 1)
);
box-shadow:
inset 0 1px 4px rgba(255, 255, 255, 0.35),
inset 0 -1px 3px rgba(139, 115, 71, 0.25),
0 3px 8px rgba(139, 115, 71, 0.35),
0 0 14px rgba(212, 175, 55, 0.3);
border-color: rgba(250, 245, 235, 0.6);
transform: scaleX(1.05);
background: linear-gradient(
to bottom,
rgba(255, 215, 140, 1),
rgba(240, 190, 117, 1),
rgba(212, 175, 55, 1)
);
box-shadow:
inset 0 1px 4px rgba(255, 255, 255, 0.35),
inset 0 -1px 3px rgba(139, 115, 71, 0.25),
0 3px 8px rgba(139, 115, 71, 0.35),
0 0 14px rgba(212, 175, 55, 0.3);
border-color: rgba(250, 245, 235, 0.6);
transform: scaleX(1.05);
}
.luxun-scrollbar::-webkit-scrollbar-thumb:active {
background: linear-gradient(
to bottom,
rgba(184, 134, 11, 1),
rgba(139, 115, 71, 1),
rgba(107, 93, 71, 1)
);
box-shadow:
inset 0 2px 5px rgba(0, 0, 0, 0.4),
inset 0 -1px 2px rgba(139, 115, 71, 0.3),
0 1px 3px rgba(139, 115, 71, 0.3);
border-color: rgba(250, 245, 235, 0.2);
background: linear-gradient(
to bottom,
rgba(184, 134, 11, 1),
rgba(139, 115, 71, 1),
rgba(107, 93, 71, 1)
);
box-shadow:
inset 0 2px 5px rgba(0, 0, 0, 0.4),
inset 0 -1px 2px rgba(139, 115, 71, 0.3),
0 1px 3px rgba(139, 115, 71, 0.3);
border-color: rgba(250, 245, 235, 0.2);
}
/* 点一点图标动画 - 手指点击动画(上下移动模拟点击) */
@keyframes finger-bounce {
0%, 100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(-10px) translateX(5px);
}
0%,
100% {
transform: translateY(0) translateX(0);
}
50% {
transform: translateY(-10px) translateX(5px);
}
}
.animate-finger-bounce {
animation: finger-bounce 1.2s ease-in-out infinite;
animation: finger-bounce 1.2s ease-in-out infinite;
}
+6
View File
@@ -4,6 +4,12 @@ import './index.css'
import App from './App'
import { useChenyunStore, useHuangyanpeiStore, usePuzzleStore, useSunzhongshanStore } from './store'
if (import.meta.env.VITE_DEBUG_ENTRY === 'true' && typeof window !== 'undefined') {
import('@/debug/progressionDebug').then(({ setupProgressionDebug }) => {
setupProgressionDebug()
})
}
// 在开发模式的移动端启用 vConsole(需先安装 vconsole 依赖)
if (import.meta.env.DEV && typeof window !== 'undefined') {
const ua = window.navigator.userAgent.toLowerCase()
+2 -15
View File
@@ -2,14 +2,12 @@ import { useState } from 'react'
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { Image, PuzzleSystem } from '@/components'
import { useNavigate } from 'react-router-dom'
import MenuPuzzleCenter from '@/components/puzzle/MenuPuzzleCenter'
import { DEFAULT_PUZZLE_RESIDENCE } from '@/config/puzzleResidences'
import { usePuzzleStore } from '@/store/puzzleStore'
import { isMenuModuleComplete } from '@/config/progression'
const Menu = () => {
const navigate = useNavigate()
const [isPuzzleCenterVisible, setIsPuzzleCenterVisible] = useState(false)
const [activeResidence, setActiveResidence] = useState<string | null>(null)
const puzzles = usePuzzleStore((state) => state.puzzles)
const isLoaded = usePuzzleStore((state) => state.isLoaded)
@@ -65,17 +63,6 @@ const Menu = () => {
/>
</div>
{/* 顶层碎片管理中心:查看故居导图与进度 */}
<MenuPuzzleCenter
visible={isPuzzleCenterVisible}
onClose={() => setIsPuzzleCenterVisible(false)}
onSelectResidence={(residence) => {
setActiveResidence(residence)
setIsPuzzleCenterVisible(false)
}}
allowPuzzleEntry={false}
/>
{/* 进入具体故居的拼图系统(与故居页面一致的 PuzzleSystem) */}
{activeResidence && (
<PuzzleSystem
@@ -102,7 +89,7 @@ const Menu = () => {
className="relative w-[241px] h-[282px] bg-cover bg-center bg-no-repeat hover:brightness-110 hover:scale-105 transition-all cursor-pointer"
style={{ backgroundImage: getBackgroundImage('menu/模块-通用') }}
key={item.id}
onClick={() => navigate(`/entrance/${encodeURIComponent(item.moduleName)}`)}
onClick={() => navigate(`/${encodeURIComponent(item.moduleName)}`)}
>
<div className="absolute top-15 flex flex-col items-center">
<Image key={item.id} src={getStaticImage(item.image)} />
@@ -132,7 +119,7 @@ const Menu = () => {
<Image
src={getStaticImage('menu/导航地图')}
button
onClick={() => setIsPuzzleCenterVisible(true)}
onClick={() => navigate('/learning-map')}
/>
</div>
</div>
+1 -1
View File
@@ -84,7 +84,7 @@ const Chenyun = () => {
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
onClick={() => navigate(`/${moduleName}`)}
/>
<div className="absolute right-20 top-75 flex flex-col space-y-10">
+24 -13
View File
@@ -55,7 +55,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
value: 'B',
label: '鸟笼比喻',
},
{ value: 'C', label: '正确的处理好计划与市场的关系', isCorrect: true },
{ value: 'C', label: '正确地处理好计划与市场的关系', isCorrect: true },
{ value: 'D', label: '经济建设的目的论', isCorrect: true },
],
explanation: '鸟笼比喻是陈云提出的一个比喻。请你再想想吧!',
@@ -69,14 +69,14 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
options: [
{
value: 'A',
label: '不唯上、不唯书、只唯实,交换、比较、反复',
label: '不唯上、不唯书、只唯实,交换、比较、反复',
isCorrect: true,
},
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{ value: 'B', label: '无农不稳,无粮则乱', isCorrect: true },
{ value: 'C', label: '个人名利淡如水,党的事业重如山', isCorrect: true },
{
value: 'D',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
label: '领导机关制定政策,要用百分之九十以上的时间作调查研究工作',
isCorrect: true,
},
],
@@ -378,7 +378,7 @@ const ChenyunInternalization = () => {
<Narration
visible={narrationVisible}
audioChannelId="chenyun-internalization-narration"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_35_1.mp3')}
onClose={() => {
setNarrationVisible(false)
setVisible(true)
@@ -417,11 +417,14 @@ const ChenyunInternalization = () => {
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_1.mp3')}
>
小沪,跟着你参观陈云纪念馆真是收获满满!看到馆里展示的“米棉之战”史料、全国财经统筹的实物展品,我真切感受到陈云爷爷在建国初期平抑物价、稳定经济的魄力。
</GuideStepsItem>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_36_2.mp3')}
>
他用“全国一盘棋”思路化解危机的案例太震撼了,那你能再跟我讲讲,支撑这些成就的核心经济思想是什么吗?
</GuideStepsItem>
<GuideStepsItem
@@ -447,18 +450,26 @@ const ChenyunInternalization = () => {
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_1.mp3')}
>
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持"银元之战""米棉之战"的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他"不唯上、不唯书、只唯实"的精神,也懂了他"为老百姓做事"的初心。
感谢房老师的讲解!从陈云童年成长的水乡故居,到他主持“银元之战”“米棉之战”的史料,再到那台称量粮食的天平、握不住的铅笔头,我真切感受到他“不唯上、不唯书、只唯实”的精神,也懂了他“为老百姓做事”的初心。
</GuideStepsItem>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_38_2.mp3')}
>
这些都让我特别受触动!
</GuideStepsItem>
<GuideStepsItem speaker="student">这些都让我特别受触动!</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_1.mp3')}
>
你的感悟很深刻!陈云同志用一生诠释了“务实”与“为民”——从破解边区经济困境,到擘画国家工业化蓝图,再到改革开放时力挺宝钢建设,每一步都扎根实际、心系百姓。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_39_2.mp3')}
>
你不妨把这份触动凝练成几句心里话,既总结参观的收获,也想想未来该如何践行这份精神,用自己的行动传承这份初心吧!
</GuideStepsItem>
</GuideSteps>
+20 -22
View File
@@ -36,12 +36,11 @@ const ChenyunPlace1 = () => {
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
// 温馨提示弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
// 学习说明对话框显示状态
const [showIntroDialog, setShowIntroDialog] = useState(false)
// 引导对话显示状态
// 温馨提示关闭后的场景对话显示状态
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null)
@@ -169,12 +168,12 @@ const ChenyunPlace1 = () => {
}}
/>
{/* 学习引导词 */}
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setVisible(true)
setShowAfterDialogGuide(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_02.mp3')}
ailgn="left"
@@ -198,18 +197,8 @@ const ChenyunPlace1 = () => {
</Overlay>
)}
{/* 场景对话 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
{/* 温馨提示关闭后的场景对话 */}
<GuideSteps visible={visible} onVisibleChange={setVisible} showMask={false}>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_03.mp3')}
@@ -218,22 +207,31 @@ const ChenyunPlace1 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_1.mp3')}
>
这座房子就是陈云故居,位于上海市青浦区练塘镇下塘街95号,是一座坐南朝北、面向河畔的两层楼江南民居。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_2.mp3')}
>
实际上是陈云舅父母的家,也是少年陈云从4岁至14岁生活的地方。童年的陈云,家庭生活十分不幸。他两岁时父亲病故,四岁时,母亲也悲苦地死去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_04_3.mp3')}
>
父母去世后,由外婆抚养。六岁时,外婆去世,陈云过继给舅父母当养子。陈云从小跟舅父廖文光和舅母廖倪氏一起生活。家里靠种田和做小生意勉强维持生计。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
{/* 温馨提示 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
onContinue={() => {
setShowAfterDialogGuide(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_01.mp3')}
firstLineIndent="2em"
>
+10 -4
View File
@@ -168,11 +168,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_1.mp3')}
>
因为家贫,初小读完的陈云便辍学在家。后在舅母亲戚的资助下,陈云才到青浦县立乙种商业学校学习珠算和记账的,他在很短时间里就基本掌握了珠算,还初步学会写账,这为陈云日后从事经济工作打下了基础。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_06_2.mp3')}
>
陈云在党内以“铁算盘”著称,他在经济工作中能熟练使用算盘,就得益于少年时在青浦县立乙种商业学校的学习。
</GuideStepsItem>
</GuideSteps>
@@ -195,11 +198,14 @@ const ChenyunPlace2 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_1.mp3')}
>
是的,陈云在青浦县立乙种商业学校学习了一个多月后,又因经济困难不得不辍学回家。多亏当地知识分子、颜安小学校长杜枢,他免费保荐陈云入颜安小学高小部读书。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_08_2.mp3')}
>
颜安小学实行的新式教育开阔了陈云的视野,五四运动后,颜安小学师生积极投入反帝爱国运动,为陈云日后加入中国共产党埋下了思想的种子。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -236,11 +236,14 @@ const ChenyunPlace3 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_1.mp3')}
>
从颜安小学毕业后,陈云进入了商务印书馆当学徒,开始在柜台接待顾客,打珠算、写信封、练包装、理货物、修文具。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_10_2.mp3')}
>
陈云的珠算能力在商务印书馆工作期间有了很大提高。由于工作认真负责,三年的学徒期,陈云提前两年就破格到商务印书馆虹口分店(现为1925书局)当店员。
</GuideStepsItem>
</GuideSteps>
+18 -6
View File
@@ -241,14 +241,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_1.mp3')}
>
1932年3月,陈云担任中共临时中央政治局常委,兼任全国总工会党团书记。1933年1月,陈云离开上海前往中央苏区首府瑞金,继续参加中共中央和全国总工会的领导工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_2.mp3')}
>
虽然陈云在中央苏区的任务主要是领导工人运动,但是陈云总是能针对一些棘手的经济问题提出较出色的建议。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_12_3.mp3')}
>
在中央苏区时期,陈云也关注和积极参与解决了许多经济难题。1934年,在中共六届五中全会上,陈云被选为中央政治局委员、常委,兼任中央白区工作部部长。
</GuideStepsItem>
</GuideSteps>
@@ -271,14 +277,20 @@ const ChenyunPlace4 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_1.mp3')}
>
中央苏区时期的经济斗争,是陈云经济工作能力的“淬炼场”。陈云分析了“左”倾经济政策的表现、危害以及原因。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_2.mp3')}
>
他强调,当务之急是重新严格审查各个企业、工厂以及商店、大小作坊的实际承受力和经济支付能力。从实际出发,实事求是地制定符合中央苏维埃区域每一个工厂、企业、作坊发展需求的劳动政策。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_14_3.mp3')}
>
陈云提议建立合作社,广泛吸收失业工人加入,多听取工人建议、增强企业管理的民主性,从而更好地建设苏区经济。这些做法为他日后主持全国财经工作积累了宝贵经验。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -165,11 +165,14 @@ const ChenyunPlace5 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_1.mp3')}
>
1944年新春伊始,陈云开始担任西北财经办事处副主任,工作转到财经方面,这是陈云以主要精力从事领导经济工作的开始。当时,陕甘宁边区在国民党严密封锁下,财经状况严峻,边区物价猛涨,老百姓日常生活必需的布匹和粮食十分缺乏。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_16_2.mp3')}
>
国民党顽固派封锁边区,企图“不让一粒粮、一尺布进入边区”,同时千方百计阻止边区食盐出口。
</GuideStepsItem>
</GuideSteps>
+5 -2
View File
@@ -134,11 +134,14 @@ const ChenyunPlace6 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_1.mp3')}
>
是的,新中国刚成立时,中国共产党接管的是一个民穷财尽、物价飞涨的烂摊子。面对财政经济上的严重困难,陈云在1949年受命担任新成立的中央财政经济委员会(简称中财委)主任,主持领导全国财政经济工作。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_18_2.mp3')}
>
陈云挂帅后的首仗就是打击投机、平抑物价。通过“银元之战”“两白一黑”战役等几次严峻斗争,只用不到一年时间,就迅速稳定了金融物价。
</GuideStepsItem>
</GuideSteps>
+12 -7
View File
@@ -44,7 +44,7 @@ const QUIZ1_TITLE =
const QUIZ1_ERROR_HINTS: Record<string, string> = {
A: '淮海战役于1949年1月胜利结束,发生在这次“银元之战”前,不存在因果关系。请你再想想吧!',
C: '1949年6月新中国政权尚未建立。请你再想想吧',
C: '1949年6月新中国政权尚未建立。请你再想想吧!',
}
const QUIZ1_FALLBACK_HINT = '再想一想,注意题干中的时间和提示信息哦~'
@@ -305,18 +305,24 @@ const ChenyunPlace7 = () => {
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_19.mp3`)}
>
那"银元之战"是如何取得胜利的呢?
那“银元之战”是如何取得胜利的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20.mp3`)}
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_1.mp3`)}
>
由于通货膨胀,国民党发行的法币、金圆券变成了一堆废纸。当时坊间流传“大街过三道,物价跳三跳”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_2.mp3`)}
>
有人计算,一百元法币,在一九三七年可买两头牛,一九四五年只能买两个鸡蛋,一九四七年只能买一个煤球,到一九四九年五月连一粒米也买不到。可见,当时通货膨胀多么严重。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_20_3.mp3`)}
>
打击投机资本,稳定金融物价,陈云代中央起草《中共中央关于打击银元使人民币占领阵地的指示》,提出以强硬手段打击投机势力。
</GuideStepsItem>
</GuideSteps>
@@ -364,7 +370,7 @@ const ChenyunPlace7 = () => {
speaker="student"
audioSrc={getPublicPath(`${AUDIO_PREFIX}/mp3_chenyun_21.mp3`)}
>
那"两白一黑"战役是如何打赢的呢?
那“两白一黑”战役是如何打赢的呢?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
@@ -395,7 +401,6 @@ const ChenyunPlace7 = () => {
title="恭喜你, 选择正确!"
content={successContent}
onClose={handleSuccessDismiss}
onContinue={handleSuccessDismiss}
/>
</div>
)}
+14 -5
View File
@@ -189,11 +189,14 @@ const ChenyunPlace8 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_1.mp3')}
>
不错,随着大规模经济建设的展开,全国粮、棉、油等主要农产品严重紧缺,供求矛盾尖锐。陈云经过调查研究,反复比较,权衡利弊得失,终于下决心采用在农村征购,在城市配售的解决粮食问题的办法,也就是后来所说的“统购统销”。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_26_2.mp3')}
>
陈云说:“只有采用这种办法,才能保证我国人民生活日益增长的需要,才能制止投机活动,保证市场物价的稳定,才能使发展国民经济第一个五年计划得以顺利地进行。”
</GuideStepsItem>
</GuideSteps>
@@ -231,14 +234,20 @@ const ChenyunPlace8 = () => {
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_1.mp3')}
>
一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_2.mp3')}
>
这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_24_3.mp3')}
>
第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过“统购统销”稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。
</GuideStepsItem>
</GuideSteps>
+9 -3
View File
@@ -147,14 +147,20 @@ const ChenyunPlace9 = () => {
>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27.mp3')}
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_1.mp3')}
>
我们已经沿着陈云的足迹,从练塘水乡走到改革开放的前沿,读懂了他“适合老百姓的要求,做了一点事”的初心。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_2.mp3')}
>
接下来,请你跨越时空,走进1949年刚解放时期的上海,参与到当时那场轰轰烈烈的“银元之战”和“米棉之战”之中去。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/chenyun/mp3_chenyun_27_3.mp3')}
>
在实践决策中体会陈云“多谋善断”“敢闯务实”的卓越智慧与战略魄力,感受我国集中力量办大事的制度优势如何在经济战场中彰显威力。
</GuideStepsItem>
</GuideSteps>
@@ -9,7 +9,7 @@ import type { ClueData } from '../clue/types'
// 银元之战视频配置
export const YINYUAN_VIDEO_CONFIG = {
src: getPublicPath('video/chenyun/chenyun_15.mp4'),
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战。你将化身陈云同志的特派员,直面这场“银元之战”。"`,
narration: `1949年5月,上海解放,此时上海经济秩序混乱,一些资本家甚至说“共产党是军事一百分,政治八十分,财经打零分。“上海经济旧势力利用人们长期以来形成的担心钞票贬值的心理,大肆贩卖银元,掀起投机风潮。上海证券交易所是投机商的大本营,而大街小巷的银元贩子却更多。原来的一块银元值100人民币,到了6月4日已经涨到1100元,而涨势仍在继续。事实上,银元已经是上海市场的主要货币。有些商号只认银元,把人民币排斥到市场之外。上海,这个全国的经济心脏,能否在新政权手中恢复跳动?一场没有硝烟的经济战争即将打响。面对这硝烟弥漫的经济战线,陈云同志亲自参与指导了同旧经济势力间的“银元之战”。”你将化身陈云同志的特派员,直面这场“银元之战”。`,
alert: {
title: '第一幕:“银元之战”--人民币的阵地争夺战',
subtitle: '上海目前出现大肆贩卖银元的情况,请你了解情况!',
@@ -36,10 +36,10 @@ export const YINYUAN_CLUE_DATA: Record<
title: '线索二',
content: (
<div className="flex flex-col items-start text-left gap-2 px-14">
<div className="mb-8">
<div className="mb-8 indent-14">
南京路的先施、永安、大新、新新等四大公司率先拒收人民币,甚至用银元标价。
</div>
<div>其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
<div className="indent-14">其他商户纷纷仿效,人民币在市场交易中地位下降。</div>
</div>
),
},
+34 -4
View File
@@ -2,7 +2,7 @@
* 陈云反思页面主入口
* 整合银元之战和米棉之战两个模块
*/
import { Image, StudyGuideModal } from '@/components'
import { Image, StudyGuideModal, Video } from '@/components'
import ErrorAlert from '@/components/layout/alert/error-alert'
import TitleAlert from '@/components/layout/alert/title-alert'
import ClueOverlay from '@/pages/chenyun/reflection/clue'
@@ -63,10 +63,10 @@ const ChenyunReflection = () => {
/** 是否显示线索弹窗 */
const [showClueOverlay, setShowClueOverlay] = useState(false)
const [showOpeningVideo, setShowOpeningVideo] = useState(true)
/** 欢迎语弹窗(使用 StudyGuideModal 展示入口引导) */
const [showWelcomeGuide, setShowWelcomeGuide] = useState(
() => !useChenyunStore.getState().isYinyuanSolved
)
const [showWelcomeGuide, setShowWelcomeGuide] = useState(false)
/** 第一幕银元之战 - 学习引导弹窗 */
const [showYinyuanGuide, setShowYinyuanGuide] = useState(false)
@@ -219,6 +219,36 @@ const ChenyunReflection = () => {
backgroundImage: getBackgroundImage(`chenyun/reflection/底图`),
}}
>
{showOpeningVideo && (
<div className="fixed inset-0 z-10000 w-full h-full bg-black">
<Video
src={getPublicPath('video/reflection/chenyun_tiwu_00.mp4')}
autoPlay
playsInline
showSkipButton
skipText="跳过"
onEnded={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
onSkip={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
onError={() => {
setShowOpeningVideo(false)
if (!useChenyunStore.getState().isYinyuanSolved) {
setShowWelcomeGuide(true)
}
}}
/>
</div>
)}
{/* 离开按钮 */}
{expandedFile == null && (
<Image
@@ -139,7 +139,7 @@ export const YinyuanBattle = ({
// 关闭多选题1,显示旁白文字(图2)
state.setShowQuiz1(false)
state.setNarration2Text(
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了"反对银元投机,保障人民生活"的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商"赶快洗手不干,否则勿谓言之不预"。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:"单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。"`
`六月五日,上海政府抛出十万银元,力图以银元制服银元,使价格回跌。全市举行了“反对银元投机,保障人民生活”的游行和宣传。上海市军委会主任陈毅在全市集会上警告投机奸商“赶快洗手不干,否则勿谓言之不预”。但这两手收效仍微。十万银元被投机商人一吸而空,六月七日银元每元的价格涨到一千八百元人民币。陈云同志判断:“单纯的经济手段已不足以震慑敌人,必须辅以坚定的行政与法律手段。”`
)
state.setShowNarration2(true)
}
@@ -77,7 +77,7 @@ export const YinyuanGuides = ({
<ExhibitOverlay
visible={showTrendChartDetail}
src="chenyun/reflection/银元价格走势图"
description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`}
description={`以上海为中心,代表“人民币信用”的绿色区域迅速覆盖全市,并向外辐射。`}
onClick={onTrendChartClick}
onClose={onTrendChartDetailClose}
/>
@@ -47,17 +47,17 @@ export const YinyuanQuizzes = ({
{/* 多选题1 - 点击引导图标后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz1}
title="【多选】面对银元投机狂潮,你认为首要突破口是? "
title="【多选】面对银元投机狂潮,你认为首要突破口是?"
options={[
{ value: 'A', label: 'A. 政治攻势(警告、约谈)' },
{ value: 'B', label: 'B. 经济手段(抛售银元,以银元打银元)' },
{ value: 'C', label: 'C. 舆论动员(宣传人民币信用,发动群众)' },
{ value: 'B', label: 'B. 经济手段(抛售银元,以银元打银元)' },
{ value: 'C', label: 'C. 舆论动员(宣传人民币信用,发动群众)' },
]}
correctAnswer={['A', 'B', 'C']}
errorMessage="单一措施效果有限,请尝试综合施策。"
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz1Submit}
onError={(error) => {
@@ -77,17 +77,17 @@ export const YinyuanQuizzes = ({
{/* 多选题2(图3)- 关闭旁白后显示 */}
<MultipleChoiceQuizPanel
visible={showQuiz2}
title="【多选】面对投机商的猖狂反扑,下一步应采取哪些决定性措施?"
title="【多选】面对投机商的猖狂反扑,下一步应采取哪些决定性措施?"
options={[
{ value: 'A', label: 'A. 加大银元抛售量,耗尽投机资本' },
{ value: 'B', label: 'B. 查封投机核心场所,逮捕首要分子' },
{ value: 'C', label: 'C. 颁布法令,严禁银元计价流通' },
{ value: 'A', label: 'A. 加大银元抛售量,耗尽投机资本' },
{ value: 'B', label: 'B. 查封投机核心场所,逮捕首要分子' },
{ value: 'C', label: 'C. 颁布法令,严禁银元计价流通' },
]}
correctAnswer={['A', 'B', 'C']}
errorMessage="单一措施效果有限,请尝试综合施策。"
errorMessage="单一措施效果有限,请尝试综合施策。"
minSelections={1}
maxSelections={3}
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
minSelectionsErrorMessage="单一措施效果有限,请尝试综合施策。"
submitText="提交"
onSubmit={onQuiz2Submit}
onError={(error) => {
@@ -98,8 +98,8 @@ export const YinyuanQuizzes = ({
{/* 成功提示框 - 多选题2答对后显示 */}
<SuccessAlert
visible={showSuccessAlert}
title="恭喜你, 选择正确!"
content="陈云同志的意见是:三管齐下,但核心是查封投机核心场所,逮捕首要分子。擒贼先擒王,打掉指挥部。"
title="恭喜你,选择正确!"
content="陈云同志的意见是:三管齐下,但核心是查封投机核心场所,逮捕首要分子。擒贼先擒王,打掉指挥部。"
onContinue={onSuccessAlertContinue}
/>
</>
+2 -2
View File
@@ -1,5 +1,5 @@
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
import { useNavigate, useParams } from 'react-router-dom'
import { Navigate, useNavigate, useParams } from 'react-router-dom'
import { Image } from '@/components'
import { isResidenceStarted, isResidenceUnlocked } from '@/config/progression'
import { usePuzzleStore } from '@/store/puzzleStore'
@@ -124,7 +124,7 @@ const Entrance = () => {
// const decodedModuleName = decodeURIComponent(moduleName || "");
const { backgroundImage, Star } = EntranceBackgroundMap[moduleName as ModuleName] || {}
if (!Star) {
return null
return <Navigate to="/menu" replace />
}
const { Star1, Star2 } = Star
const isStar1Unlocked = isResidenceUnlocked(Star1.starName, puzzles)
+1 -1
View File
@@ -79,7 +79,7 @@ const Huangyanpei = () => {
className="absolute right-37 top-22"
src={getStaticImage('components/革命先驱故居-返回')}
button
onClick={() => navigate(`/entrance/${moduleName}`)}
onClick={() => navigate(`/${moduleName}`)}
/>
<div className="absolute right-20 top-75 flex flex-col space-y-10">
+7 -4
View File
@@ -141,7 +141,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
},
{
value: 'D',
label: '在川沙小学堂采取"半天读书、半天做手工"的学习模式',
label: '在川沙小学堂采取“半天读书、半天做手工”的学习模式',
isCorrect: true,
},
],
@@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
time: 369, // 6:09
title: '',
prompt:
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的"国货"已经自强自立,请将其中的国产企业/品牌放置到空格处',
'【拖拽】当年黄炎培先生有推广国货的爱国情怀,如今我们的“国货”已经自强自立,请将其中的国产企业/品牌放置到空格处',
type: 'drag',
options: [
{ value: '特斯拉', label: '特斯拉' },
@@ -488,11 +488,14 @@ const HuangyanpeiInternalization = () => {
>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28.mp3')}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28_1.mp3')}
>
小沪,刚刚结束的“小职成长记”太有意思了!我变成百年前的川沙少年选课程、做手工、设计国货推销方案,好像真的走进了黄炎培先生的职教世界。
</GuideStepsItem>
<GuideStepsItem speaker="student">
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_28_2.mp3')}
>
但我还有一个疑问:先生当年倡导的“手脑并用、工学结合”,放在我们现在的中职学习中,到底有什么样的意义呢?
</GuideStepsItem>
<GuideStepsItem
+18 -23
View File
@@ -31,10 +31,9 @@ const HuangyanpeiPlace1 = () => {
// 学习引导弹窗
const [showGuideModal, setShowGuideModal] = useState(true)
// 对话结束后的学习引导弹窗
// 温馨提示弹窗
const [showAfterDialogGuide, setShowAfterDialogGuide] = useState(false)
const [afterDialogGuideShown, setAfterDialogGuideShown] = useState(false)
// 引导对话显示状态
// 温馨提示关闭后的引导对话显示状态
const [visible, setVisible] = useState(false)
// 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识)
const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null)
@@ -123,12 +122,12 @@ const HuangyanpeiPlace1 = () => {
}}
/>
{/* 学习引导词 */}
{/* 学习引导 */}
<StudyGuideModal
visible={showGuideModal}
onContinue={() => {
setShowGuideModal(false)
setVisible(true)
setShowAfterDialogGuide(true)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_02.mp3')}
ailgn="left"
@@ -140,44 +139,40 @@ const HuangyanpeiPlace1 = () => {
3.所有动态光点的展品全部学习后才能进入下一个场景学习。
</StudyGuideModal>
{/* 引导 */}
<GuideSteps
visible={visible}
onVisibleChange={(nextVisible) => {
setVisible(nextVisible)
if (!nextVisible && !afterDialogGuideShown) {
setAfterDialogGuideShown(true)
setShowAfterDialogGuide(true)
}
}}
showMask={false}
>
{/* 温馨提示关闭后的引导对话 */}
<GuideSteps visible={visible} onVisibleChange={setVisible} showMask={false}>
<GuideStepsItem
speaker="student"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_03.mp3')}
>
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗
小沪,这栋古色古香的江南大院,就是黄炎培先生从小生活、读书的地方吗?
</GuideStepsItem>
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_04.mp3')}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_04_1.mp3')}
>
没错,1878年10月1日,黄炎培先生就出生在当时江苏省川沙县城关镇(现上海市浦东新区川沙镇)的这处“内史第”,宅子原是他外祖父沈树镛家,也是他后来所说的“生于斯、读于斯、长于斯”之地。
</GuideStepsItem>
<GuideStepsItem speaker="guide">
<GuideStepsItem
speaker="guide"
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_04_2.mp3')}
>
1890年,川沙创办“观澜书院”,12岁的黄炎培以第一名的成绩考入,每天提着母亲亲手缝制的布书包,往返十里路求学,一点都不叫苦。1893年他去上海应县考,松江府复试名列前茅;1899年,在松江府试中更是以第一名的成绩中了秀才。
</GuideStepsItem>
</GuideSteps>
{/* 对话结束后的学习引导 */}
{/* 温馨提示 */}
<StudyGuideModal
visible={showAfterDialogGuide}
onContinue={() => setShowAfterDialogGuide(false)}
onContinue={() => {
setShowAfterDialogGuide(false)
setVisible(true)
}}
audioSrc={getPublicPath('audio/huangyanpei/mp3_huangyanpei_01.mp3')}
firstLineIndent="2em"
>
同学你好,欢迎来到“走进故居·对话”学习模块!{'\n'}
此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里五段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
此刻你正站在黄炎培先生故居门前,接下来就跟着时光的脉络,拆解黄先生人生里5段滚烫的旅程:思想启蒙、教育实践、创立职教、延安对话、职教传薪。准备好一起走进这段炽热的岁月了吗?
</StudyGuideModal>
</div>
)
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more