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

+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