feat: add puzzle residence configuration and reflection progress logic
- Introduced `puzzleResidences.ts` to define puzzle residence configurations including properties like display name, position, and puzzle availability. - Added `reflectionProgress.ts` to manage badge unlocking logic based on place completion, including functions to determine unlocked badges and video sources for scenes.
This commit is contained in:
1 parent
e0fa47d21a
commit
f5f36f75b5
153 files changed
+3846
-2352
No files matched your search
@@ -29,7 +29,7 @@ const ExhibitDescriptionNarration = ({
|
||||
visible = true,
|
||||
autoPlay = true,
|
||||
audioSrc,
|
||||
audioChannelId,
|
||||
audioChannelId = 'exhibit-description-narration',
|
||||
className,
|
||||
}: ExhibitDescriptionNarrationProps) => {
|
||||
// 音频管理:默认全局互斥,如果传了 audioChannelId 则按 channel 互斥
|
||||
@@ -89,7 +89,7 @@ const ExhibitDescriptionNarration = ({
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col gap-4">
|
||||
{/* 文本内容 */}
|
||||
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium">
|
||||
<div className="flex-1 text-white text-2xl leading-12 tracking-wide font-medium indent-12">
|
||||
{text}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { FlipActionButton, Image } from '@/components'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
@@ -10,6 +10,14 @@ const OVERLAY_STYLE: React.CSSProperties = {
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
|
||||
const isDirectImageSrc = (src: string) =>
|
||||
src.startsWith('http://') ||
|
||||
src.startsWith('https://') ||
|
||||
src.startsWith('/') ||
|
||||
src.startsWith('data:image/')
|
||||
|
||||
const resolveImageSrc = (src: string) => (isDirectImageSrc(src) ? src : getStaticImage(src))
|
||||
|
||||
export interface ExhibitFlipOverlayProps {
|
||||
/** 展品正面图片地址,可以是静态资源路径或完整 URL */
|
||||
src: string
|
||||
@@ -29,6 +37,10 @@ export interface ExhibitFlipOverlayProps {
|
||||
visible?: boolean
|
||||
/** 额外的自定义类 */
|
||||
className?: string
|
||||
/** 底部操作区 */
|
||||
footer?: ReactNode
|
||||
/** 底部操作区额外类名 */
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +57,8 @@ const ExhibitFlipOverlay = ({
|
||||
onClose,
|
||||
visible = true,
|
||||
className,
|
||||
footer,
|
||||
footerClassName,
|
||||
}: ExhibitFlipOverlayProps) => {
|
||||
const backSrc = getStaticImage('components/close')
|
||||
|
||||
@@ -63,18 +77,11 @@ const ExhibitFlipOverlay = ({
|
||||
}
|
||||
}, [visible, src, flipSrc])
|
||||
|
||||
const frontImageSrc =
|
||||
src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
|
||||
? src
|
||||
: getStaticImage(src)
|
||||
const frontImageSrc = resolveImageSrc(src)
|
||||
|
||||
const backImageSrc = useMemo(() => {
|
||||
if (!flipSrc) return null
|
||||
return flipSrc.startsWith('http://') ||
|
||||
flipSrc.startsWith('https://') ||
|
||||
flipSrc.startsWith('/')
|
||||
? flipSrc
|
||||
: getStaticImage(flipSrc)
|
||||
return resolveImageSrc(flipSrc)
|
||||
}, [flipSrc])
|
||||
|
||||
const canFlip = Boolean(backImageSrc && backImageSrc !== frontImageSrc)
|
||||
@@ -200,6 +207,20 @@ const ExhibitFlipOverlay = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showChrome && footer && (
|
||||
<motion.div
|
||||
className={cn(
|
||||
'absolute left-1/2 bottom-38 z-30 -translate-x-1/2 pointer-events-auto',
|
||||
footerClassName
|
||||
)}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
{footer}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ const GuideSteps = ({
|
||||
highlightSelectors,
|
||||
showMask = true,
|
||||
zIndex = 50,
|
||||
audioChannelId,
|
||||
audioChannelId = 'guide-steps-default',
|
||||
}: GuideStepsProps) => {
|
||||
const [internalStep, setInternalStep] = useState(0)
|
||||
const [internalVisible, setInternalVisible] = useState(true)
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface StudyGuideModalProps {
|
||||
ailgn?: 'center' | 'left' | 'right'
|
||||
// 垂直对齐方式,默认 center
|
||||
verticalAlign?: 'center' | 'top' | 'bottom'
|
||||
/** 正文首行缩进,如 '2em'、'32px' 或 32 */
|
||||
firstLineIndent?: string | number
|
||||
/** 引导语音地址(可选) */
|
||||
audioSrc?: string
|
||||
/** 弹窗出现时是否自动播放引导语音(默认 true) */
|
||||
@@ -55,6 +57,7 @@ export const StudyGuideModal = ({
|
||||
zIndex = 120,
|
||||
ailgn = 'center',
|
||||
verticalAlign = 'top',
|
||||
firstLineIndent,
|
||||
audioSrc,
|
||||
autoPlayAudio = true,
|
||||
audioChannelId,
|
||||
@@ -152,6 +155,12 @@ export const StudyGuideModal = ({
|
||||
)}
|
||||
style={{
|
||||
marginTop: verticalAlign === 'center' ? '85px' : '',
|
||||
textIndent:
|
||||
firstLineIndent === undefined
|
||||
? undefined
|
||||
: typeof firstLineIndent === 'number'
|
||||
? `${firstLineIndent}px`
|
||||
: firstLineIndent,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Outlet, useMatches, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import type { VideoRouteHandle } from '@/router/types'
|
||||
@@ -21,6 +21,8 @@ const RESIDENCES_WITH_PUZZLE = [
|
||||
'sunzhongshan',
|
||||
'luxun',
|
||||
'maozedong',
|
||||
'nieer',
|
||||
'zhangleping',
|
||||
] as const
|
||||
|
||||
/**
|
||||
@@ -48,6 +50,8 @@ const ResidenceLayoutContent = () => {
|
||||
const [isFlying, setIsFlying] = useState(false) // 标记是否正在飞行
|
||||
const puzzlePieceRef = useRef<HTMLDivElement>(null)
|
||||
const animationInProgressRef = useRef<boolean>(false) // 防止重复执行动画
|
||||
const animationCompletionRef = useRef<(() => void) | null>(null)
|
||||
const animationCompletionCallbackRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等
|
||||
// 路径格式:/:moduleName/:starName/:page
|
||||
@@ -77,106 +81,142 @@ const ResidenceLayoutContent = () => {
|
||||
OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出)
|
||||
}
|
||||
|
||||
const resolveAnimation = useCallback(() => {
|
||||
const callback = animationCompletionCallbackRef.current
|
||||
const resolve = animationCompletionRef.current
|
||||
|
||||
animationCompletionCallbackRef.current = null
|
||||
animationCompletionRef.current = null
|
||||
|
||||
callback?.()
|
||||
resolve?.()
|
||||
}, [])
|
||||
|
||||
// 清理动画状态
|
||||
const cleanupAnimation = () => {
|
||||
const cleanupAnimation = useCallback(() => {
|
||||
setIsAnimationPlaying(false)
|
||||
setShowPuzzlePiece(false)
|
||||
setPuzzlePieceId(null)
|
||||
setIsFlying(false)
|
||||
animationInProgressRef.current = false
|
||||
}
|
||||
resolveAnimation()
|
||||
}, [resolveAnimation])
|
||||
|
||||
// 执行飞行动画
|
||||
const executeFlyAnimation = (pieceId: number) => {
|
||||
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||
return false
|
||||
}
|
||||
const executeFlyAnimation = useCallback(
|
||||
(pieceId: number) => {
|
||||
if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) {
|
||||
return false
|
||||
}
|
||||
|
||||
setIsFlying(true)
|
||||
setIsFlying(true)
|
||||
|
||||
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
if (!puzzlePieceRef.current || !residence) {
|
||||
// 使用 requestAnimationFrame 确保元素已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
if (!puzzlePieceRef.current || !residence) {
|
||||
return
|
||||
}
|
||||
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: 'center',
|
||||
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||
scale: ANIMATION_CONFIG.SCALE,
|
||||
opacity: ANIMATION_CONFIG.OPACITY,
|
||||
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
|
||||
onComplete: () => {
|
||||
if (residence) {
|
||||
unlockPuzzle(residence, pieceId)
|
||||
}
|
||||
cleanupAnimation()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
[ANIMATION_CONFIG.FLY_DURATION, ANIMATION_CONFIG.OPACITY, ANIMATION_CONFIG.SCALE, cleanupAnimation, puzzleBoxRef, residence, unlockPuzzle]
|
||||
)
|
||||
|
||||
|
||||
// 等待元素准备就绪并执行动画
|
||||
const waitForElementsAndAnimate = useCallback(
|
||||
async (pieceId: number, retryCount = 0): Promise<void> => {
|
||||
if (!residence) {
|
||||
cleanupAnimation()
|
||||
return
|
||||
}
|
||||
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: 'center',
|
||||
duration: ANIMATION_CONFIG.FLY_DURATION,
|
||||
scale: ANIMATION_CONFIG.SCALE,
|
||||
opacity: ANIMATION_CONFIG.OPACITY,
|
||||
ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速
|
||||
onComplete: () => {
|
||||
if (residence) {
|
||||
unlockPuzzle(residence, pieceId)
|
||||
}
|
||||
cleanupAnimation()
|
||||
},
|
||||
})
|
||||
})
|
||||
// 检查元素是否已准备好
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
const success = executeFlyAnimation(pieceId)
|
||||
if (success) return
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1)
|
||||
}
|
||||
|
||||
// 等待元素准备就绪并执行动画
|
||||
const waitForElementsAndAnimate = async (pieceId: number, retryCount = 0): Promise<void> => {
|
||||
if (!residence) {
|
||||
// 超过最大重试次数,直接解锁并清理
|
||||
console.warn('Puzzle animation: 元素未准备好,直接解锁')
|
||||
unlockPuzzle(residence, pieceId)
|
||||
cleanupAnimation()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查元素是否已准备好
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
const success = executeFlyAnimation(pieceId)
|
||||
if (success) return
|
||||
}
|
||||
|
||||
// 如果元素未准备好且未超过最大重试次数,则重试
|
||||
if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY))
|
||||
return waitForElementsAndAnimate(pieceId, retryCount + 1)
|
||||
}
|
||||
|
||||
// 超过最大重试次数,直接解锁并清理
|
||||
console.warn('Puzzle animation: 元素未准备好,直接解锁')
|
||||
unlockPuzzle(residence, pieceId)
|
||||
cleanupAnimation()
|
||||
}
|
||||
},
|
||||
[ANIMATION_CONFIG.MAX_RETRIES, ANIMATION_CONFIG.RETRY_DELAY, cleanupAnimation, executeFlyAnimation, puzzleBoxRef, residence, unlockPuzzle]
|
||||
)
|
||||
|
||||
// 处理碎片动画触发(仅支持拼图功能的故居)
|
||||
const handleTriggerPuzzleAnimation = (pieceId: number) => {
|
||||
if (!residence || !hasPuzzleFeature) return
|
||||
const handleTriggerPuzzleAnimation = useCallback(
|
||||
(pieceId: number, options?: { onComplete?: () => void }) => {
|
||||
if (!residence || !hasPuzzleFeature) {
|
||||
options?.onComplete?.()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 防止重复执行动画
|
||||
if (animationInProgressRef.current) return
|
||||
// 防止重复执行动画
|
||||
if (animationInProgressRef.current) {
|
||||
options?.onComplete?.()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 检查碎片是否已解锁
|
||||
if (isPuzzleUnlocked(residence, pieceId)) return
|
||||
// 检查碎片是否已解锁
|
||||
if (isPuzzleUnlocked(residence, pieceId)) {
|
||||
options?.onComplete?.()
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// 标记动画正在进行
|
||||
animationInProgressRef.current = true
|
||||
// 标记动画正在进行
|
||||
animationInProgressRef.current = true
|
||||
|
||||
// 开始动画流程:禁用所有操作
|
||||
setIsAnimationPlaying(true)
|
||||
setPuzzlePieceId(pieceId)
|
||||
setShowPuzzlePiece(true)
|
||||
return new Promise<void>((resolve) => {
|
||||
animationCompletionRef.current = resolve
|
||||
animationCompletionCallbackRef.current = options?.onComplete ?? null
|
||||
|
||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// 等待初始出现动画(旋转+放大)完成
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
|
||||
// 开始动画流程:禁用所有操作
|
||||
setIsAnimationPlaying(true)
|
||||
setPuzzlePieceId(pieceId)
|
||||
setShowPuzzlePiece(true)
|
||||
|
||||
// 等待发光动画显示一段时间,让用户看到碎片
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
|
||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(async () => {
|
||||
// 等待初始出现动画(旋转+放大)完成
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION))
|
||||
|
||||
// 等待元素准备就绪并执行飞行动画
|
||||
await waitForElementsAndAnimate(pieceId)
|
||||
// 等待发光动画显示一段时间,让用户看到碎片
|
||||
await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION))
|
||||
|
||||
// 等待元素准备就绪并执行飞行动画
|
||||
await waitForElementsAndAnimate(pieceId)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[ANIMATION_CONFIG.APPEAR_DURATION, ANIMATION_CONFIG.GLOW_DURATION, hasPuzzleFeature, isPuzzleUnlocked, residence, waitForElementsAndAnimate]
|
||||
)
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getStaticImage, getBackgroundImage } from '@/lib/utils'
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAudioManager } from '@/hooks/useAudioManager'
|
||||
|
||||
export interface SuccessAlertProps {
|
||||
@@ -42,6 +42,46 @@ const SuccessAlert = ({
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
audioChannelId ? { mode: 'channel', channelId: audioChannelId } : { mode: 'global' }
|
||||
)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [shouldIndent, setShouldIndent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const element = contentRef.current
|
||||
if (!element || !content) {
|
||||
setShouldIndent(false)
|
||||
return
|
||||
}
|
||||
|
||||
const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight)
|
||||
if (!lineHeight) {
|
||||
setShouldIndent(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShouldIndent(element.scrollHeight - lineHeight > 1)
|
||||
}, [content, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!content) return
|
||||
|
||||
const handleResize = () => {
|
||||
const element = contentRef.current
|
||||
if (!element) return
|
||||
|
||||
const lineHeight = Number.parseFloat(window.getComputedStyle(element).lineHeight)
|
||||
if (!lineHeight) {
|
||||
setShouldIndent(false)
|
||||
return
|
||||
}
|
||||
|
||||
setShouldIndent(element.scrollHeight - lineHeight > 1)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [content])
|
||||
|
||||
const contentStyle = shouldIndent ? { textIndent: '2em' } : undefined
|
||||
|
||||
// 弹窗显示时自动播放音频
|
||||
useEffect(() => {
|
||||
@@ -105,7 +145,11 @@ const SuccessAlert = ({
|
||||
|
||||
{/* 内容文本 */}
|
||||
{content && (
|
||||
<div className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="text-white text-4xl font-medium max-w-[1600px] leading-relaxed px-20"
|
||||
style={contentStyle}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MatchQuizPanelProps } from './types'
|
||||
import { useScale } from '@/contexts/ScaleContext'
|
||||
import type { MatchQuizItem, MatchQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* 连线题面板组件
|
||||
* 支持移动端触摸操作
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MatchQuizPanel
|
||||
* visible={true}
|
||||
* title="请将日期与对应的事件连线"
|
||||
* leftItems={[
|
||||
* { id: "1", label: "1892-1898年" },
|
||||
* { id: "2", label: "1919-1923年" },
|
||||
* ]}
|
||||
* rightItems={[
|
||||
* { id: "a", label: "在绍兴三味书屋求学积淀" },
|
||||
* { id: "b", label: "于北京八道湾参加新文化运动" },
|
||||
* ]}
|
||||
* correctMatches={[
|
||||
* { leftId: "1", rightId: "a" },
|
||||
* { leftId: "2", rightId: "b" },
|
||||
* ]}
|
||||
* onSubmit={(matches, isCorrect) => {
|
||||
* console.log("匹配结果:", matches, "是否正确:", isCorrect);
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const MatchQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
@@ -41,23 +15,26 @@ const MatchQuizPanel = ({
|
||||
onError,
|
||||
disabled = false,
|
||||
}: MatchQuizPanelProps) => {
|
||||
const { scale } = useScale()
|
||||
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string>('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const middleRef = useRef<HTMLDivElement>(null)
|
||||
const leftColRef = useRef<HTMLDivElement>(null)
|
||||
const rightColRef = useRef<HTMLDivElement>(null)
|
||||
const quizAreaRef = useRef<HTMLDivElement>(null)
|
||||
const leftItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const rightItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
// 当前错误的连线对,用于做红色高亮+抖动反馈
|
||||
// ✓/✕ 圆点的 ref
|
||||
const leftDotRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const rightDotRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const [errorPairs, setErrorPairs] = useState<Array<{ leftId: string; rightId: string }>>([])
|
||||
const [leftCenters, setLeftCenters] = useState<Record<string, number>>({})
|
||||
const [rightCenters, setRightCenters] = useState<Record<string, number>>({})
|
||||
const [middleHeight, setMiddleHeight] = useState<number>(300)
|
||||
// 连线端点坐标(相对于 quizArea)
|
||||
const [lineAnchors, setLineAnchors] = useState<
|
||||
Record<string, { startX: number; startY: number; endX: number; endY: number }>
|
||||
>({})
|
||||
const [svgSize, setSvgSize] = useState({ width: 0, height: 300 })
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
const useRowAlignedDesktopLayout = leftItems.length === rightItems.length
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setMatches([])
|
||||
@@ -67,11 +44,9 @@ const MatchQuizPanel = ({
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 处理左列项点击
|
||||
const handleLeftItemClick = (leftId: string) => {
|
||||
if (disabled) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.leftId === leftId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.leftId !== leftId))
|
||||
@@ -80,41 +55,35 @@ const MatchQuizPanel = ({
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经选中了另一个左项,取消之前的选中
|
||||
if (selectedLeftId && selectedLeftId !== leftId) {
|
||||
setSelectedLeftId(leftId)
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 切换选中状态
|
||||
setSelectedLeftId(selectedLeftId === leftId ? null : leftId)
|
||||
setError('')
|
||||
}
|
||||
|
||||
// 处理右列项点击
|
||||
const handleRightItemClick = (rightId: string) => {
|
||||
if (disabled) return
|
||||
|
||||
// 如果没有选中左项,直接返回
|
||||
if (!selectedLeftId) return
|
||||
|
||||
// 如果已经匹配过,先取消匹配
|
||||
const existingMatch = matches.find((m) => m.rightId === rightId)
|
||||
if (existingMatch) {
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId))
|
||||
setSelectedLeftId(null)
|
||||
if (existingMatch.leftId === selectedLeftId) {
|
||||
setMatches(matches.filter((m) => m.rightId !== rightId))
|
||||
setSelectedLeftId(null)
|
||||
}
|
||||
setError('')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的匹配
|
||||
const newMatch = { leftId: selectedLeftId, rightId }
|
||||
const newMatches = [...matches, newMatch]
|
||||
|
||||
// 每次连线后立即校验当前所有连线是否存在错误
|
||||
const wrongMatches =
|
||||
correctMatches?.filter(() => true) && // 保证 correctMatches 存在时才校验
|
||||
correctMatches?.filter(() => true) &&
|
||||
newMatches.filter(
|
||||
(match) =>
|
||||
!correctMatches?.some((cm) => cm.leftId === match.leftId && cm.rightId === match.rightId)
|
||||
@@ -123,7 +92,6 @@ const MatchQuizPanel = ({
|
||||
const hasError = !!wrongMatches && wrongMatches.length > 0
|
||||
|
||||
if (hasError && wrongMatches) {
|
||||
// 记录错误连线对,用于红色高亮+抖动反馈
|
||||
setErrorPairs(wrongMatches)
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
@@ -137,9 +105,9 @@ const MatchQuizPanel = ({
|
||||
} else {
|
||||
errorMsg = '连线不正确,请重新尝试!'
|
||||
}
|
||||
|
||||
setError(errorMsg)
|
||||
onError?.(errorMsg)
|
||||
// 短暂展示错误状态(变红+抖动),随后只清空错误连线,保留已答对的连线
|
||||
setTimeout(() => {
|
||||
setMatches((prev) =>
|
||||
prev.filter(
|
||||
@@ -151,93 +119,124 @@ const MatchQuizPanel = ({
|
||||
return
|
||||
}
|
||||
|
||||
// 没有错误时,正常记录匹配
|
||||
setMatches(newMatches)
|
||||
setSelectedLeftId(null)
|
||||
setError('')
|
||||
|
||||
// 如果没有错误且所有项都已连完,则认为答题成功
|
||||
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
|
||||
onSubmit?.(newMatches, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取左项是否已匹配
|
||||
const isLeftItemMatched = (leftId: string) => {
|
||||
return matches.some((m) => m.leftId === leftId)
|
||||
}
|
||||
const isLeftItemMatched = (leftId: string) => matches.some((m) => m.leftId === leftId)
|
||||
const isRightItemMatched = (rightId: string) => matches.some((m) => m.rightId === rightId)
|
||||
|
||||
// 获取右项是否已匹配
|
||||
const isRightItemMatched = (rightId: string) => {
|
||||
return matches.some((m) => m.rightId === rightId)
|
||||
}
|
||||
const getAnchorForMatch = useCallback(
|
||||
(match: { leftId: string; rightId: string }) => {
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return null
|
||||
|
||||
const measureCenters = useCallback(() => {
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
const leftCard = leftItemRefs.current[match.leftId]
|
||||
const rightCard = rightItemRefs.current[match.rightId]
|
||||
const leftDot = leftDotRefs.current[match.leftId]
|
||||
const rightDot = rightDotRefs.current[match.rightId]
|
||||
if (!leftCard || !rightCard || !leftDot || !rightDot) return null
|
||||
|
||||
const middleRect = middleEl.getBoundingClientRect()
|
||||
const nextLeft: Record<string, number> = {}
|
||||
const nextRight: Record<string, number> = {}
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
const leftAnchorRect = leftDot.getBoundingClientRect()
|
||||
const rightAnchorRect = rightDot.getBoundingClientRect()
|
||||
|
||||
// 显式同步中间连线层高度,避免因 flex/stretch/内容换行导致坐标系基准漂移
|
||||
const leftRect = leftColRef.current?.getBoundingClientRect()
|
||||
const rightRect = rightColRef.current?.getBoundingClientRect()
|
||||
const nextMiddleHeight = Math.max(leftRect?.height ?? 0, rightRect?.height ?? 0, 300)
|
||||
setMiddleHeight(nextMiddleHeight)
|
||||
return {
|
||||
startX: (leftAnchorRect.left + leftAnchorRect.width / 2 - areaRect.left) / scale,
|
||||
startY: (leftAnchorRect.top + leftAnchorRect.height / 2 - areaRect.top) / scale,
|
||||
endX: (rightAnchorRect.left + rightAnchorRect.width / 2 - areaRect.left) / scale,
|
||||
endY: (rightAnchorRect.top + rightAnchorRect.height / 2 - areaRect.top) / scale,
|
||||
}
|
||||
},
|
||||
[scale]
|
||||
)
|
||||
|
||||
for (const item of leftItems) {
|
||||
const el = leftItemRefs.current[item.id]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
nextLeft[item.id] = rect.top + rect.height / 2 - middleRect.top
|
||||
}
|
||||
const measureAnchors = useCallback(() => {
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
for (const item of rightItems) {
|
||||
const el = rightItemRefs.current[item.id]
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
nextRight[item.id] = rect.top + rect.height / 2 - middleRect.top
|
||||
}
|
||||
|
||||
setLeftCenters(nextLeft)
|
||||
setRightCenters(nextRight)
|
||||
}, [leftItems, rightItems])
|
||||
|
||||
const scheduleMeasure = useCallback(() => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
// 双 RAF:等待浏览器完成一帧布局/绘制(含 transform/transition/pulse 的本帧生效)
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null
|
||||
measureCenters()
|
||||
})
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
setSvgSize({
|
||||
width: areaRect.width / scale,
|
||||
height: areaRect.height / scale,
|
||||
})
|
||||
}, [measureCenters])
|
||||
|
||||
// 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测
|
||||
const next: Record<string, { startX: number; startY: number; endX: number; endY: number }> = {}
|
||||
|
||||
for (const match of matches) {
|
||||
const anchor = getAnchorForMatch(match)
|
||||
if (!anchor) continue
|
||||
next[`${match.leftId}-${match.rightId}`] = anchor
|
||||
}
|
||||
|
||||
setLineAnchors(next)
|
||||
}, [getAnchorForMatch, matches, scale])
|
||||
|
||||
const scheduleMeasure = useCallback(
|
||||
(mode: 'all' | 'new-only' = 'all') => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null
|
||||
|
||||
if (mode === 'all') {
|
||||
measureAnchors()
|
||||
return
|
||||
}
|
||||
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
const areaRect = quizAreaEl.getBoundingClientRect()
|
||||
setSvgSize({
|
||||
width: areaRect.width / scale,
|
||||
height: areaRect.height / scale,
|
||||
})
|
||||
|
||||
setLineAnchors((prev) => {
|
||||
const next: Record<
|
||||
string,
|
||||
{ startX: number; startY: number; endX: number; endY: number }
|
||||
> = {}
|
||||
|
||||
for (const match of matches) {
|
||||
const key = `${match.leftId}-${match.rightId}`
|
||||
next[key] = prev[key] ??
|
||||
getAnchorForMatch(match) ?? { startX: 0, startY: 0, endX: 0, endY: 0 }
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
[getAnchorForMatch, matches, measureAnchors, scale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
scheduleMeasure()
|
||||
}, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure])
|
||||
scheduleMeasure('new-only')
|
||||
}, [visible, matches, scheduleMeasure])
|
||||
|
||||
// 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行)
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
const quizAreaEl = quizAreaRef.current
|
||||
if (!quizAreaEl) return
|
||||
|
||||
const handleResize = () => scheduleMeasure()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
const ro = new ResizeObserver(() => scheduleMeasure())
|
||||
ro.observe(middleEl)
|
||||
ro.observe(quizAreaEl)
|
||||
if (containerRef.current) ro.observe(containerRef.current)
|
||||
if (leftColRef.current) ro.observe(leftColRef.current)
|
||||
if (rightColRef.current) ro.observe(rightColRef.current)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
@@ -246,6 +245,125 @@ const MatchQuizPanel = ({
|
||||
}
|
||||
}, [visible, scheduleMeasure])
|
||||
|
||||
const renderLeftItem = (item: MatchQuizItem) => {
|
||||
const isMatched = isLeftItemMatched(item.id)
|
||||
const isSelected = selectedLeftId === item.id
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
leftItemRefs.current[item.id] = el
|
||||
}}
|
||||
className={cn(
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
|
||||
'touch-manipulation',
|
||||
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
isError
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isSelected
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isError && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
>
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderRightItem = (item: MatchQuizItem) => {
|
||||
const isMatched = isRightItemMatched(item.id)
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
rightItemRefs.current[item.id] = el
|
||||
}}
|
||||
className={cn(
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed',
|
||||
'touch-manipulation',
|
||||
disabled || isMatched
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: selectedLeftId
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-default',
|
||||
isError
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: selectedLeftId
|
||||
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isError && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
>
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
@@ -255,13 +373,9 @@ const MatchQuizPanel = ({
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white text-center">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && (
|
||||
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
@@ -269,163 +383,78 @@ const MatchQuizPanel = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 连线区域 */}
|
||||
<div className="w-full max-w-[1600px] flex flex-col md:flex-row gap-4 md:gap-8 items-start md:items-stretch justify-center">
|
||||
{/* 左列:日期 */}
|
||||
<div ref={leftColRef} className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{leftItems.map((item) => {
|
||||
const isMatched = isLeftItemMatched(item.id)
|
||||
const isSelected = selectedLeftId === item.id
|
||||
const isError = errorPairs.some((p) => p.leftId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
leftItemRefs.current[item.id] = el
|
||||
}}
|
||||
className={cn(
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed cursor-pointer',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
isError
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isSelected
|
||||
? 'bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
)}
|
||||
onClick={() => handleLeftItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
// 移动端触摸优化
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isError && (
|
||||
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
|
||||
<div
|
||||
ref={middleRef}
|
||||
className="hidden md:block w-16 relative shrink-0"
|
||||
style={{ height: middleHeight }}
|
||||
>
|
||||
{/* 绘制连线 */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
|
||||
{matches.map((match, index) => {
|
||||
const leftItem = leftItems.find((item) => item.id === match.leftId)
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId)
|
||||
if (!leftItem || !rightItem) return null
|
||||
|
||||
const startY = leftCenters[match.leftId]
|
||||
const endY = rightCenters[match.rightId]
|
||||
if (startY === undefined || endY === undefined) return null
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${match.leftId}-${match.rightId}-${index}`}
|
||||
x1="0"
|
||||
y1={startY}
|
||||
x2="100%"
|
||||
y2={endY}
|
||||
stroke="#f0be75"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 右列:事件 */}
|
||||
<div ref={rightColRef} className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
{rightItems.map((item) => {
|
||||
const isMatched = isRightItemMatched(item.id)
|
||||
const isError = errorPairs.some((p) => p.rightId === item.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
rightItemRefs.current[item.id] = el
|
||||
}}
|
||||
className={cn(
|
||||
'relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[20px] md:text-[28px] leading-relaxed',
|
||||
'touch-manipulation', // 优化移动端触摸
|
||||
disabled || isMatched
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
: selectedLeftId
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-default',
|
||||
isError
|
||||
? 'bg-[#fbe9e9] text-[#b91c1c] border-[#fca5a5] shadow-[0_0_15px_rgba(248,113,113,0.7)] scale-105 animate-pulse'
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: selectedLeftId
|
||||
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-linear-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
: 'bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60'
|
||||
)}
|
||||
onClick={() => handleRightItemClick(item.id)}
|
||||
onTouchStart={(e) => {
|
||||
if (selectedLeftId) {
|
||||
e.currentTarget.classList.add('scale-105')
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.classList.remove('scale-105')
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{isError && (
|
||||
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]">
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 移动端 */}
|
||||
<div className="w-full max-w-[1600px] md:hidden flex flex-col gap-4 items-start justify-center">
|
||||
<div className="w-full flex flex-col gap-3">{leftItems.map(renderLeftItem)}</div>
|
||||
<div className="w-full flex flex-col gap-3">{rightItems.map(renderRightItem)}</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面端 */}
|
||||
<div ref={quizAreaRef} className="hidden md:block w-full max-w-[1600px] relative">
|
||||
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none overflow-visible z-10"
|
||||
width={svgSize.width}
|
||||
height={svgSize.height}
|
||||
viewBox={`0 0 ${Math.max(svgSize.width, 1)} ${Math.max(svgSize.height, 1)}`}
|
||||
>
|
||||
{matches.map((match, index) => {
|
||||
const key = `${match.leftId}-${match.rightId}`
|
||||
const anchor = lineAnchors[key]
|
||||
if (!anchor) return null
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${key}-${index}`}
|
||||
x1={anchor.startX}
|
||||
y1={anchor.startY}
|
||||
x2={anchor.endX}
|
||||
y2={anchor.endY}
|
||||
stroke="#f0be75"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="5,5"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{useRowAlignedDesktopLayout ? (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_64px_minmax(0,1fr)] gap-x-8 gap-y-4 items-stretch">
|
||||
{leftItems.map((leftItem, index) => {
|
||||
const rightItem = rightItems[index]
|
||||
if (!rightItem) return null
|
||||
|
||||
return (
|
||||
<div key={`row-${leftItem.id}-${rightItem.id}`} className="contents">
|
||||
<div className="min-h-full">{renderLeftItem(leftItem)}</div>
|
||||
<div className="w-16" />
|
||||
<div className="min-h-full">{renderRightItem(rightItem)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-8 items-stretch justify-center">
|
||||
<div className="w-1/2 flex flex-col gap-4">{leftItems.map(renderLeftItem)}</div>
|
||||
<div className="w-16 shrink-0" />
|
||||
<div className="w-1/2 flex flex-col gap-4">{rightItems.map(renderRightItem)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端连线提示 */}
|
||||
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
|
||||
请先点击左侧日期,再点击右侧对应的事件进行连线
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,14 @@ import { filterInput } from '@/lib/utils/sensitiveWords'
|
||||
import { Image, Select, Video, type VideoRef } from '@/components'
|
||||
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}`
|
||||
}
|
||||
|
||||
export type LetterFieldType = 'text' | 'input' | 'select'
|
||||
|
||||
export interface LetterFieldOption {
|
||||
@@ -47,6 +55,7 @@ export interface LetterTemplateProps {
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyVideoSrc?: string
|
||||
sceneImageSrc?: string
|
||||
replyButtonImageSrc?: string
|
||||
replyImageSrc?: string
|
||||
@@ -74,19 +83,26 @@ const LetterTemplate = ({
|
||||
audioSrc,
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyVideoSrc,
|
||||
sceneImageSrc = 'components/letter/动画结束后场景',
|
||||
replyButtonImageSrc = 'components/letter/点击敬读',
|
||||
replyImageSrc,
|
||||
}: LetterTemplateProps) => {
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
const [signatureName, setSignatureName] = useState('')
|
||||
const [signatureError, setSignatureError] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showSendVideo, setShowSendVideo] = useState(false)
|
||||
const [showReplyVideo, setShowReplyVideo] = useState(false)
|
||||
const [showScene, setShowScene] = useState(false)
|
||||
const [showReplyImage, setShowReplyImage] = useState(false)
|
||||
const sendVideoRef = useRef<VideoRef>(null)
|
||||
const replyVideoRef = useRef<VideoRef>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const currentDate = useMemo(() => formatCurrentDate(), [])
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() => (typeof fields === 'function' ? fields(blankValues) : fields),
|
||||
[fields, blankValues]
|
||||
@@ -99,6 +115,13 @@ const LetterTemplate = ({
|
||||
(_, index) => blankValues[index] ?? ''
|
||||
)
|
||||
|
||||
const trimmedSignatureName = signatureName.trim()
|
||||
if (!trimmedSignatureName) {
|
||||
showHint('请填写落款姓名后再提交哦!')
|
||||
setSignatureError(true)
|
||||
return false
|
||||
}
|
||||
|
||||
const nextErrors = validate
|
||||
? validate(nextValues, letterContent)
|
||||
: nextValues.map((value, index) => {
|
||||
@@ -146,7 +169,10 @@ const LetterTemplate = ({
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setSignatureName('')
|
||||
setSignatureError(false)
|
||||
setShowSendVideo(false)
|
||||
setShowReplyVideo(false)
|
||||
setShowScene(false)
|
||||
setShowReplyImage(false)
|
||||
}
|
||||
@@ -157,14 +183,14 @@ const LetterTemplate = ({
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audioSrc) return
|
||||
|
||||
if (visible && !showSendVideo) {
|
||||
if (visible && !showSendVideo && !showScene && !showReplyImage && !showReplyVideo) {
|
||||
audio.currentTime = 0
|
||||
audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}, [visible, showSendVideo, audioSrc])
|
||||
}, [visible, showSendVideo, showScene, showReplyImage, showReplyVideo, audioSrc])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -252,6 +278,48 @@ const LetterTemplate = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleReplyVideoCanPlay = () => {
|
||||
if (showReplyVideo && replyVideoRef.current) {
|
||||
replyVideoRef.current.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignatureNameChange = (value: string) => {
|
||||
const nextValue = filterInput(value)
|
||||
setSignatureName(nextValue)
|
||||
|
||||
const hasError = !nextValue.trim()
|
||||
if (signatureError !== hasError) {
|
||||
setSignatureError(hasError)
|
||||
}
|
||||
}
|
||||
|
||||
const renderSignature = (readonly: boolean) => {
|
||||
if (readonly) {
|
||||
return (
|
||||
<div className="absolute right-[240px] bottom-[110px] z-99 flex flex-col items-end text-[26px] leading-[1.8] text-[#4e402e]">
|
||||
<span>{signatureName.trim() || ' '}</span>
|
||||
<span>{currentDate}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute right-[190px] bottom-[120px] z-99 flex flex-col items-end gap-2 text-[#4e402e]">
|
||||
<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]'}`}
|
||||
style={{ fontSize: '26px' }}
|
||||
autoComplete="off"
|
||||
value={signatureName}
|
||||
maxLength={12}
|
||||
onChange={(e) => handleSignatureNameChange(e.target.value)}
|
||||
/>
|
||||
<span className="text-[26px] leading-[1.8]">{currentDate}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderLine = (readonly: boolean) => {
|
||||
let placeholderIndex = 0
|
||||
let bodyStarted = false
|
||||
@@ -354,7 +422,7 @@ const LetterTemplate = ({
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
// onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -416,6 +484,8 @@ const LetterTemplate = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSignature(false)}
|
||||
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
@@ -424,7 +494,7 @@ const LetterTemplate = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<div className="absolute bottom-[80px] left-62 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
@@ -468,7 +538,9 @@ const LetterTemplate = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
{renderSignature(true)}
|
||||
|
||||
<div className="absolute bottom-[80px] left-50 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
@@ -577,12 +649,42 @@ const LetterTemplate = ({
|
||||
onClick={() => {
|
||||
setShowReplyImage(false)
|
||||
setShowScene(false)
|
||||
if (replyVideoSrc) {
|
||||
setShowReplyVideo(true)
|
||||
return
|
||||
}
|
||||
onClose?.()
|
||||
onAnimationEnd?.()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showReplyVideo && replyVideoSrc && (
|
||||
<motion.div
|
||||
key="reply-video"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="fixed inset-0 z-104 h-full w-full bg-black"
|
||||
>
|
||||
<Video
|
||||
ref={replyVideoRef}
|
||||
src={getPublicPath(replyVideoSrc)}
|
||||
className="h-full w-full"
|
||||
playsInline
|
||||
onCanPlay={handleReplyVideoCanPlay}
|
||||
onEnded={() => {
|
||||
setShowReplyVideo(false)
|
||||
onClose?.()
|
||||
onAnimationEnd?.()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToChenyunProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的陈云爷爷:
|
||||
@@ -31,7 +32,11 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '勤学钻研',
|
||||
correctValue: '实',
|
||||
readOnlyFallback: '实',
|
||||
hints: {
|
||||
error: '“不唯上、不唯书、只唯实”是陈云提出的著名方法论原则。',
|
||||
},
|
||||
},
|
||||
2: {
|
||||
type: 'input',
|
||||
@@ -62,6 +67,7 @@ const LetterToChenyun = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-陈云',
|
||||
replyVideoSrc = 'video/letter/chenyun_gaizhang.mp4',
|
||||
}: LetterToChenyunProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -69,12 +75,13 @@ const LetterToChenyun = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给陈云的信"
|
||||
title="写给陈云爷爷的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToHuangyanpeiProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -73,6 +74,7 @@ const LetterToHuangyanpei = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-黄炎培',
|
||||
replyVideoSrc = 'video/letter/huangyanpei_gaizhang.mp4',
|
||||
}: LetterToHuangyanpeiProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -87,6 +89,7 @@ const LetterToHuangyanpei = ({
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToLuxunProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -17,7 +18,7 @@ const commonSelectField: LetterFieldConfig = {
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '旨在立人', label: '旨在立人' },
|
||||
{ value: '首在立人', label: '首在立人' },
|
||||
{ value: '批判求真', label: '批判求真' },
|
||||
],
|
||||
}
|
||||
@@ -28,18 +29,18 @@ const letterContent = `敬爱的鲁迅先生:
|
||||
|
||||
今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
我是____专业的学生,今后,我会以您“____千夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
|
||||
请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
...commonSelectField,
|
||||
correctValue: '旨在立人',
|
||||
correctValue: '首在立人',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '旨在立人',
|
||||
readOnlyFallback: '首在立人',
|
||||
},
|
||||
1: {
|
||||
...commonSelectField,
|
||||
@@ -85,8 +86,8 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
type: 'input',
|
||||
maxLength: 16,
|
||||
widthClassName: 'w-[120px]',
|
||||
correctValue: '世上本没有路',
|
||||
readOnlyFallback: '世上本没有路',
|
||||
correctValue: '地上本没有路',
|
||||
readOnlyFallback: '地上本没有路',
|
||||
hints: {
|
||||
error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”',
|
||||
},
|
||||
@@ -108,6 +109,7 @@ const LetterToLuxun = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-鲁迅',
|
||||
replyVideoSrc = 'video/letter/luxun_gaizhang.mp4',
|
||||
}: LetterToLuxunProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -115,12 +117,13 @@ const LetterToLuxun = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给鲁迅的信"
|
||||
title="写给鲁迅先生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToMaozedongProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -44,7 +45,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。',
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
@@ -52,7 +53,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。',
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
@@ -60,7 +61,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '独立自主',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。',
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '独立自主',
|
||||
},
|
||||
@@ -75,7 +76,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
],
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。',
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
@@ -90,7 +91,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
],
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。',
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
@@ -128,20 +129,24 @@ const LetterToMaozedong = ({
|
||||
audioSrc = 'audio/letter/1东方红.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyVideoSrc = 'video/letter/mzd_gaizhang.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-毛泽东',
|
||||
}: LetterToMaozedongProps) => {
|
||||
// const stampedVideoSrc = videoSrc.replace('neihua_jixin.mp4', 'maozedong_gaizhang.mp4')
|
||||
|
||||
return (
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给毛泽东的信"
|
||||
title="写给毛泽东主席的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToNieerProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -87,6 +88,7 @@ const LetterToNieer = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-聂耳',
|
||||
replyVideoSrc = 'video/letter/nieer_gaizhang.mp4',
|
||||
}: LetterToNieerProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -94,13 +96,14 @@ const LetterToNieer = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给聂耳的信"
|
||||
title="写给聂耳先生的信"
|
||||
letterContent={letterContent}
|
||||
contentClassName="!text-[25px]"
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToRongdeshengProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -79,6 +80,7 @@ const LetterToRongdesheng = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-荣德生',
|
||||
replyVideoSrc = 'video/letter/rongdesheng_gaizhang.mp4',
|
||||
}: LetterToRongdeshengProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -86,12 +88,13 @@ const LetterToRongdesheng = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给荣德生的信"
|
||||
title="写给荣德生先生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToSunzhongshanProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
@@ -92,6 +93,7 @@ const LetterToSunzhongshan = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-孙中山',
|
||||
replyVideoSrc = 'video/letter/sunzhongshan_gaizhang.mp4',
|
||||
}: LetterToSunzhongshanProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -99,12 +101,13 @@ const LetterToSunzhongshan = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给孙中山的信"
|
||||
title="写给孙中山先生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface LetterToZhanglepingProps {
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
replyVideoSrc?: string
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的张乐平先生:
|
||||
@@ -102,6 +103,7 @@ const LetterToZhangleping = ({
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-张乐平',
|
||||
replyVideoSrc = 'video/letter/zhangleping_gaizhang.mp4',
|
||||
}: LetterToZhanglepingProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
@@ -109,12 +111,13 @@ const LetterToZhangleping = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给张乐平的信"
|
||||
title="写给张乐平先生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyVideoSrc={replyVideoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -129,7 +129,7 @@ const Narration = ({
|
||||
<div
|
||||
className={cn(
|
||||
'w-[1600px] bg-black/90 rounded-[32px] px-20 py-16',
|
||||
'shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]',
|
||||
'shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] indent-14',
|
||||
'relative',
|
||||
className
|
||||
)}
|
||||
@@ -155,7 +155,7 @@ const Narration = ({
|
||||
onClick={handleClose}
|
||||
className="px-12 py-2 bg-gradient-to-br from-[#d4c5a9] to-[#b8a88a] text-[#4e402e] text-3xl font-bold rounded-full border-3 border-[#8B7355] shadow-lg hover:scale-105 transition-transform"
|
||||
>
|
||||
已知晓
|
||||
继续
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const EndingOverlay = ({
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-10000 flex items-center justify-center bg-transparent">
|
||||
<div className="fixed inset-0 z-10000 flex items-center justify-center bg-black/40">
|
||||
{/* 文本内容层 */}
|
||||
<div
|
||||
className="relative z-10 flex items-center w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
|
||||
@@ -122,7 +122,7 @@ export const EndingOverlay = ({
|
||||
backgroundImage: `url(${getStaticImage('components/overlay/bg')})`,
|
||||
}}
|
||||
>
|
||||
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide">
|
||||
<div className="text-4xl leading-relaxed text-[#7b3f00] font-semibold tracking-wide indent-18">
|
||||
{displayedText}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
|
||||
/**
|
||||
* 六边形进度条组件
|
||||
@@ -104,119 +105,22 @@ export interface MenuPuzzleCenterProps {
|
||||
onClose: () => void
|
||||
/** 选择某个故居后的回调(进入对应的碎片系统) */
|
||||
onSelectResidence: (residence: string) => void
|
||||
/** 是否允许从管理中心进入具体拼图 */
|
||||
allowPuzzleEntry?: boolean
|
||||
}
|
||||
|
||||
interface ResidenceConfig {
|
||||
/** 拼图存储用的英文 key(如 chenyun) */
|
||||
residence: string
|
||||
/** 显示名称 */
|
||||
displayName: string
|
||||
/** 在大地图上的水平位置(百分比字符串,如 "20%") */
|
||||
left: string
|
||||
/** 在大地图上的垂直位置(百分比字符串,如 "40%") */
|
||||
top: string
|
||||
/** 显示用图片路径(相对于 puzzle/home/ 目录) */
|
||||
image: string
|
||||
/** 是否已经开放拼图(未开放时只展示,不可点击) */
|
||||
hasPuzzle: boolean
|
||||
/** 是否有导图资源 */
|
||||
hasMap?: boolean
|
||||
/** 导图资源文件名(位于 puzzle/home/map 下) */
|
||||
mapImage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层碎片管理中心中展示的故居列表(8 个故居)
|
||||
* left / top 为大致位置,后续可以根据视觉效果微调
|
||||
* image 资源都位于 puzzle/home 目录下,命名可按实际资源调整
|
||||
*/
|
||||
const RESIDENCE_LIST: ResidenceConfig[] = [
|
||||
{
|
||||
residence: 'maozedong',
|
||||
displayName: '毛泽东旧居',
|
||||
left: '30%',
|
||||
top: '35%',
|
||||
image: '毛泽东',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: '毛泽东导图',
|
||||
},
|
||||
{
|
||||
residence: 'sunzhongshan',
|
||||
displayName: '孙中山故居',
|
||||
left: '12%',
|
||||
top: '51%',
|
||||
image: '孙中山',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: '孙中山导图',
|
||||
},
|
||||
{
|
||||
residence: 'luxun',
|
||||
displayName: '鲁迅故居',
|
||||
left: '26.3%',
|
||||
top: '74%',
|
||||
image: '鲁迅',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: '鲁迅导图',
|
||||
},
|
||||
{
|
||||
residence: 'huangyanpei',
|
||||
displayName: '黄炎培故居',
|
||||
left: '44%',
|
||||
top: '67%',
|
||||
image: '黄炎培',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: '黄炎培导图',
|
||||
},
|
||||
{
|
||||
residence: 'nieer',
|
||||
displayName: '聂耳旧居',
|
||||
left: '62%',
|
||||
top: '60%',
|
||||
image: '聂耳',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: 'zhangleping',
|
||||
displayName: '张乐平故居',
|
||||
left: '73%',
|
||||
top: '33%',
|
||||
image: '张乐平',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: 'rongdesheng',
|
||||
displayName: '荣德生旧居',
|
||||
left: '90%',
|
||||
top: '45%',
|
||||
image: '荣德生',
|
||||
hasPuzzle: true,
|
||||
hasMap: false,
|
||||
},
|
||||
{
|
||||
residence: 'chenyun',
|
||||
displayName: '陈云故居',
|
||||
left: '80%',
|
||||
top: '76%',
|
||||
image: '陈云',
|
||||
hasPuzzle: true,
|
||||
hasMap: true,
|
||||
mapImage: '陈云导图',
|
||||
},
|
||||
]
|
||||
|
||||
const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCenterProps) => {
|
||||
const MenuPuzzleCenter = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectResidence,
|
||||
allowPuzzleEntry = true,
|
||||
}: MenuPuzzleCenterProps) => {
|
||||
const { getPuzzleCollectProgress } = usePuzzleStore()
|
||||
const [activeMapImage, setActiveMapImage] = useState<string | null>(null)
|
||||
|
||||
const residenceProgress = useMemo(
|
||||
() =>
|
||||
RESIDENCE_LIST.map((item) => ({
|
||||
PUZZLE_RESIDENCES.map((item) => ({
|
||||
...item,
|
||||
// 只有已开放拼图的故居才计算收集进度
|
||||
progress: item.hasPuzzle ? getPuzzleCollectProgress(item.residence) : 0,
|
||||
@@ -287,16 +191,18 @@ const MenuPuzzleCenter = ({ visible, onClose, onSelectResidence }: MenuPuzzleCen
|
||||
|
||||
{/* 图标 */}
|
||||
<div className="flex justify-center gap-6">
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/拼图`)}
|
||||
alt="拼图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
if (!hasPuzzle) return
|
||||
e.stopPropagation()
|
||||
onSelectResidence(residence)
|
||||
}}
|
||||
/>
|
||||
{allowPuzzleEntry && (
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/拼图`)}
|
||||
alt="拼图"
|
||||
button
|
||||
onClick={(e) => {
|
||||
if (!hasPuzzle) return
|
||||
e.stopPropagation()
|
||||
onSelectResidence(residence)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={getStaticImage(`puzzle/home/导图`)}
|
||||
alt="导图"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn, getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||
import { getPuzzlePieceData } from '@/config/puzzleData'
|
||||
import { getPuzzlePiecePath, getPuzzleViewBox } from '@/config/puzzlePaths'
|
||||
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||||
import { Image } from '@/components'
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,9 @@ const getResidenceDisplayName = (residence: string): string => {
|
||||
luxun: '鲁迅',
|
||||
sunzhongshan: '孙中山',
|
||||
maozedong: '毛泽东',
|
||||
zhangleping: '张乐平',
|
||||
nieer: '聂耳',
|
||||
rongdesheng: '荣德生',
|
||||
}
|
||||
|
||||
return nameMap[residence] || residence
|
||||
@@ -28,6 +32,10 @@ export interface PuzzleSystemProps {
|
||||
onClose: () => void
|
||||
/** 故居名称,如 'chenyun' */
|
||||
residence: string
|
||||
/** 是否显示菜单入口的故居导航 */
|
||||
showResidenceNav?: boolean
|
||||
/** 切换故居 */
|
||||
onResidenceChange?: (residence: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +151,13 @@ const PuzzlePieceSvg = ({
|
||||
* - 已解锁显示正常图片,未解锁显示遮罩
|
||||
* - 点击碎片可跳转到对应页面
|
||||
*/
|
||||
const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
const PuzzleSystem = ({
|
||||
visible,
|
||||
onClose,
|
||||
residence,
|
||||
showResidenceNav = false,
|
||||
onResidenceChange,
|
||||
}: PuzzleSystemProps) => {
|
||||
const navigate = useNavigate()
|
||||
const { moduleName } = useParams<{ moduleName: string }>()
|
||||
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore()
|
||||
@@ -154,9 +168,15 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedPiece(null)
|
||||
setHoveredPiece(null)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPiece(null)
|
||||
setHoveredPiece(null)
|
||||
}, [residence])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
// 背景图片
|
||||
@@ -177,13 +197,13 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
|
||||
// 处理前往按钮点击
|
||||
const handleGoToPage = () => {
|
||||
if (moduleName && selectedPiece) {
|
||||
if (activeModuleName && selectedPiece) {
|
||||
// console.log(residence, selectedPiece, moduleName);
|
||||
// const route = getPuzzleRoute(residence, selectedPiece, moduleName);
|
||||
// console.log(route);
|
||||
const route = selectedPieceData?.route
|
||||
if (route) {
|
||||
navigate(`/${moduleName}/${residence}/${route}`)
|
||||
navigate(`/${activeModuleName}/${residence}/${route}`)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -200,6 +220,12 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
|
||||
// 获取收集进度
|
||||
const collectProgress = getPuzzleCollectProgress(residence)
|
||||
const activeResidenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence)
|
||||
const activeModuleName = moduleName || activeResidenceConfig?.moduleName
|
||||
const residenceNavItems = PUZZLE_RESIDENCES.filter((item) => item.hasPuzzle).map((item) => ({
|
||||
...item,
|
||||
progress: getPuzzleCollectProgress(item.residence),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -221,6 +247,37 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
}}
|
||||
>
|
||||
{showResidenceNav && (
|
||||
<div className="absolute left-0 top-0 z-10 h-full w-[360px] pt-10 flex flex-col gap-3 pointer-events-auto">
|
||||
{residenceNavItems.map((item) => {
|
||||
const isActive = item.residence === residence
|
||||
const progress = Math.round(item.progress)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.residence}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative w-[341px] h-[112px] bg-contain bg-center bg-no-repeat text-left transition-all hover:brightness-110',
|
||||
isActive ? 'translate-x-3 scale-105' : ''
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage(
|
||||
isActive ? 'puzzle/选中' : 'puzzle/未选中'
|
||||
)})`,
|
||||
}}
|
||||
onClick={() => onResidenceChange?.(item.residence)}
|
||||
>
|
||||
<div className="relative h-full pl-14 pr-5 flex items-center justify-between text-white font-bold">
|
||||
<span className="text-2xl drop-shadow">{item.displayName}</span>
|
||||
<span className="text-xl drop-shadow">{progress}%</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
@@ -259,7 +316,9 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
</div>
|
||||
|
||||
{/* 完整拼图底图 - 作为背景显示所有碎片拼好的样子 */}
|
||||
<div className="absolute inset-0 flex justify-end">
|
||||
<div
|
||||
className={cn('absolute inset-0 flex justify-end', showResidenceNav ? 'pl-[360px]' : '')}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-6 pt-30">
|
||||
<div className="w-200 h-200 perspective-[1000px]">
|
||||
{/* SVG拼图容器 - 使用3D变换和缩放 */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import DragFill from './drag-fill'
|
||||
import type { DragFillProps } from './drag-fill.types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const meta: Meta<typeof DragFill> = {
|
||||
@@ -47,6 +46,25 @@ const meta: Meta<typeof DragFill> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof DragFill>
|
||||
|
||||
function ControlledDragFillStory() {
|
||||
const [value, setValue] = useState<any[]>([])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragFill
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(results) => {
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -80,23 +98,7 @@ export const WithObjects: Story = {
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<any[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DragFill
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
blanks={['空白1', '空白2']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(results) => {
|
||||
alert(`提交的结果: ${JSON.stringify(results)}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">当前填充: {JSON.stringify(value)}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
render: () => <ControlledDragFillStory />,
|
||||
}
|
||||
|
||||
// 允许重复选择
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import MultipleChoice from './multiple-choice'
|
||||
import type { MultipleChoiceProps } from './multiple-choice.types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const meta: Meta<typeof MultipleChoice> = {
|
||||
@@ -47,6 +46,26 @@ const meta: Meta<typeof MultipleChoice> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof MultipleChoice>
|
||||
|
||||
function ControlledMultipleChoiceStory() {
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MultipleChoice
|
||||
options={['选项A', '选项B', '选项C', '选项D']}
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
onSubmit={(vals) => {
|
||||
alert(`提交的值: ${vals.join(', ')}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -75,24 +94,7 @@ export const WithOptionObjects: Story = {
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MultipleChoice
|
||||
options={['选项A', '选项B', '选项C', '选项D']}
|
||||
value={values}
|
||||
onChange={setValues}
|
||||
onSubmit={(vals) => {
|
||||
alert(`提交的值: ${vals.join(', ')}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
当前选中: {values.length > 0 ? values.join(', ') : '未选择'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
render: () => <ControlledMultipleChoiceStory />,
|
||||
}
|
||||
|
||||
// 限制最大选择数量
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { useState } from 'react'
|
||||
import SingleChoice from './single-choice'
|
||||
import type { SingleChoiceProps } from './single-choice.types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const meta: Meta<typeof SingleChoice> = {
|
||||
@@ -39,6 +38,24 @@ const meta: Meta<typeof SingleChoice> = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof SingleChoice>
|
||||
|
||||
function ControlledSingleChoiceStory() {
|
||||
const [value, setValue] = useState<string>('')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SingleChoice
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(val) => {
|
||||
alert(`提交的值: ${val}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 基础示例
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
@@ -66,22 +83,7 @@ export const WithOptionObjects: Story = {
|
||||
|
||||
// 受控模式
|
||||
export const Controlled: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<string>('')
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SingleChoice
|
||||
options={['选项A', '选项B', '选项C']}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={(val) => {
|
||||
alert(`提交的值: ${val}`)
|
||||
}}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">当前选中: {value || '未选择'}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
render: () => <ControlledSingleChoiceStory />,
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
|
||||
Reference in new issue
Block a user