更新依赖项及历史人物相关内容资源
- 更新 package.json 和 package-lock.json 中的依赖项 - 新增/更新陈云、黄炎培、鲁迅、孙中山的音频文件 - 新增场景和组件的图片资源 - 清理未使用的音频和图片文件 - 优化多个组件和页面的实现逻辑
This commit is contained in:
1 parent
b1d55b9464
commit
e0fa47d21a
629 files changed
+24390
-2991
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { forwardRef } from 'react'
|
||||
import type { BaseButtonProps } from './button.types'
|
||||
|
||||
export type FlipActionButtonProps = Omit<BaseButtonProps, 'variant'>
|
||||
|
||||
/**
|
||||
* 通用动作按钮(建议配合场景自定义 children)
|
||||
* - 平面风格,结构简单
|
||||
* - 强按下反馈(缩放/压暗/内描边)
|
||||
*/
|
||||
const FlipActionButton = forwardRef<HTMLButtonElement, FlipActionButtonProps>(
|
||||
(
|
||||
{ children, className = '', disabled = false, loading = false, type = 'button', ...props },
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type as 'button' | 'submit' | 'reset' | undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-3',
|
||||
'rounded-[999px] px-7 py-3',
|
||||
'text-white text-4xl font-bold',
|
||||
'bg-[#B08D6A] hover:bg-[#bf9973]',
|
||||
'transition-[transform,filter,background-color] duration-150',
|
||||
'active:scale-[0.96] active:brightness-95',
|
||||
'shadow-sm',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-black/40',
|
||||
'disabled:opacity-50 disabled:pointer-events-none',
|
||||
// 内描边:更像“按下”的反馈
|
||||
'active:shadow-[inset_0_0_0_2px_rgba(255,255,255,.20)]',
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? '加载中...' : children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
FlipActionButton.displayName = 'FlipActionButton'
|
||||
|
||||
export default FlipActionButton
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './button'
|
||||
export * from './button.types'
|
||||
export { default as FlipActionButton } from './FlipActionButton'
|
||||
export type { FlipActionButtonProps } from './FlipActionButton'
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Image } from '@/components'
|
||||
import { getBackgroundImage, getStaticImage } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Clue } from '@/store/clueStore'
|
||||
import type { Clue } from '@/store/sunzhongshanStore'
|
||||
|
||||
export interface ClueBookModalProps {
|
||||
visible: boolean
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FlipActionButton, Image } from '@/components'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const OVERLAY_STYLE: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
|
||||
export interface ExhibitFlipOverlayProps {
|
||||
/** 展品正面图片地址,可以是静态资源路径或完整 URL */
|
||||
src: string
|
||||
/** 展品翻面(背面)图片地址,可以是静态资源路径或完整 URL */
|
||||
flipSrc?: string
|
||||
/**
|
||||
* 展品默认展示缩放(用于“默认放大”)
|
||||
* - 1 表示原尺寸逻辑
|
||||
* - > 1 会放大展品展示(仍受 max-w/max-h 约束)
|
||||
*/
|
||||
displayScale?: number
|
||||
/** 展品入场动画播放完成(背景/按钮出现前后) */
|
||||
onEnterComplete?: () => void
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void
|
||||
/** 是否可见,默认 true */
|
||||
visible?: boolean
|
||||
/** 额外的自定义类 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量展品翻转层
|
||||
* - 不使用 Portal
|
||||
* - 不支持缩放/拖拽
|
||||
* - 点击展品区域翻转显示另一张图(带 3D 翻转动画)
|
||||
*/
|
||||
const ExhibitFlipOverlay = ({
|
||||
src,
|
||||
flipSrc,
|
||||
displayScale = 1,
|
||||
onEnterComplete,
|
||||
onClose,
|
||||
visible = true,
|
||||
className,
|
||||
}: ExhibitFlipOverlayProps) => {
|
||||
const backSrc = getStaticImage('components/close')
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [flipped, setFlipped] = useState(false)
|
||||
const [showChrome, setShowChrome] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFlipped(false)
|
||||
setShowChrome(false)
|
||||
}
|
||||
}, [visible, src, flipSrc])
|
||||
|
||||
const frontImageSrc =
|
||||
src.startsWith('http://') || src.startsWith('https://') || src.startsWith('/')
|
||||
? src
|
||||
: getStaticImage(src)
|
||||
|
||||
const backImageSrc = useMemo(() => {
|
||||
if (!flipSrc) return null
|
||||
return flipSrc.startsWith('http://') ||
|
||||
flipSrc.startsWith('https://') ||
|
||||
flipSrc.startsWith('/')
|
||||
? flipSrc
|
||||
: getStaticImage(flipSrc)
|
||||
}, [flipSrc])
|
||||
|
||||
const canFlip = Boolean(backImageSrc && backImageSrc !== frontImageSrc)
|
||||
|
||||
if (!mounted || !visible) return null
|
||||
|
||||
return (
|
||||
<div style={OVERLAY_STYLE}>
|
||||
<div
|
||||
className={cn('absolute inset-0 flex items-center justify-center', className)}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
{/* 第二段:背景与按钮在展品入场后再出现 */}
|
||||
{showChrome && (
|
||||
<>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-black/70"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
/>
|
||||
|
||||
{onClose && (
|
||||
<motion.div
|
||||
className="absolute top-5 right-5 z-20"
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
<Image
|
||||
src={backSrc}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
alt="返回"
|
||||
button
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative z-10 w-[900px] h-[900px] max-w-[90%] max-h-[90%] flex items-center justify-center select-none"
|
||||
style={{
|
||||
perspective: 1200,
|
||||
WebkitPerspective: '1200px',
|
||||
}}
|
||||
>
|
||||
{/* 第一段:像“获得装备”一样先把展品弹出 */}
|
||||
<motion.div
|
||||
className={cn(
|
||||
'relative w-full h-full',
|
||||
'transition-transform duration-500 ease-[cubic-bezier(0.2,0.8,0.2,1)]'
|
||||
)}
|
||||
initial={{ scale: 0.25, opacity: 0, rotateY: 0 }}
|
||||
animate={{ scale: displayScale, opacity: 1, rotateY: flipped ? 180 : 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 520,
|
||||
damping: 26,
|
||||
mass: 0.8,
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
if (!showChrome) {
|
||||
setShowChrome(true)
|
||||
onEnterComplete?.()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
transformStyle: 'preserve-3d',
|
||||
WebkitTransformStyle: 'preserve-3d',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={frontImageSrc}
|
||||
alt="展品"
|
||||
className="max-w-full max-h-full object-contain drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canFlip && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitBackfaceVisibility: 'hidden',
|
||||
transform: 'rotateY(180deg)',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={backImageSrc!}
|
||||
alt="展品背面"
|
||||
className="max-w-full max-h-full object-contain drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{showChrome && canFlip && (
|
||||
<motion.div
|
||||
className="absolute bottom-50 right-0 z-20"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
<FlipActionButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFlipped((v) => !v)
|
||||
}}
|
||||
>
|
||||
翻转一下
|
||||
</FlipActionButton>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExhibitFlipOverlay
|
||||
@@ -1,4 +1,6 @@
|
||||
export { default as ExhibitOverlay } from './ExhibitOverlay'
|
||||
export type { ExhibitOverlayProps } from './ExhibitOverlay'
|
||||
export { default as ExhibitFlipOverlay } from './ExhibitFlipOverlay'
|
||||
export type { ExhibitFlipOverlayProps } from './ExhibitFlipOverlay'
|
||||
export { default as ExhibitDescriptionNarration } from './ExhibitDescriptionNarration'
|
||||
export type { ExhibitDescriptionNarrationProps } from './ExhibitDescriptionNarration'
|
||||
@@ -39,6 +39,12 @@ interface GuideStepsItemProps {
|
||||
autoPlayOnEnter?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* GuideStepsItem 文案长度建议:
|
||||
* - 不要一句一页,也不要超长导致一页放不下
|
||||
* - 推荐每段 1~2 句,建议不超过 70 个中文字符
|
||||
*/
|
||||
|
||||
const GuideSteps = ({
|
||||
children,
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
|
||||
export type HintPositionOffset = {
|
||||
/** 相对窗口中心点的水平偏移(px) */
|
||||
x?: number
|
||||
/** 相对窗口中心点的垂直偏移(px) */
|
||||
y?: number
|
||||
}
|
||||
|
||||
export interface ShowHintOptions {
|
||||
/** 自动消失时间(ms)。默认 2000 */
|
||||
durationMs?: number
|
||||
/** 是否常驻(常驻时不会自动消失) */
|
||||
persistent?: boolean
|
||||
/** 相对中心点微调位置 */
|
||||
offset?: HintPositionOffset
|
||||
/** 自定义层级(默认 60) */
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
type HintState = {
|
||||
visible: boolean
|
||||
text: string
|
||||
options: Required<Pick<ShowHintOptions, 'durationMs' | 'persistent' | 'offset' | 'zIndex'>>
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: HintState['options'] = {
|
||||
durationMs: 2000,
|
||||
persistent: false,
|
||||
offset: { x: 0, y: 0 },
|
||||
zIndex: 60,
|
||||
}
|
||||
|
||||
let current: HintState = {
|
||||
visible: false,
|
||||
text: '',
|
||||
options: DEFAULT_OPTIONS,
|
||||
}
|
||||
|
||||
let hideTimer: number | null = null
|
||||
const listeners = new Set<(s: HintState) => void>()
|
||||
|
||||
function emit(next: HintState) {
|
||||
current = next
|
||||
listeners.forEach((l) => l(current))
|
||||
}
|
||||
|
||||
function clearHideTimer() {
|
||||
if (hideTimer) {
|
||||
window.clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function showHint(text: string, options?: ShowHintOptions) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
clearHideTimer()
|
||||
|
||||
const mergedOptions: HintState['options'] = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...(options ?? {}),
|
||||
offset: {
|
||||
...DEFAULT_OPTIONS.offset,
|
||||
...(options?.offset ?? {}),
|
||||
},
|
||||
}
|
||||
|
||||
emit({
|
||||
visible: true,
|
||||
text,
|
||||
options: mergedOptions,
|
||||
})
|
||||
|
||||
if (!mergedOptions.persistent) {
|
||||
hideTimer = window.setTimeout(() => {
|
||||
hideHint()
|
||||
}, mergedOptions.durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
export function hideHint() {
|
||||
if (typeof window === 'undefined') return
|
||||
clearHideTimer()
|
||||
emit({ ...current, visible: false })
|
||||
}
|
||||
|
||||
export function HintHost() {
|
||||
const [state, setState] = useState<HintState>(current)
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(setState)
|
||||
return () => {
|
||||
listeners.delete(setState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const offsetX = state.options.offset.x ?? 0
|
||||
const offsetY = state.options.offset.y ?? 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none" style={{ zIndex: state.options.zIndex }}>
|
||||
<AnimatePresence>
|
||||
{state.visible && state.text && (
|
||||
<motion.div
|
||||
key="hint"
|
||||
className="absolute left-1/2"
|
||||
style={{
|
||||
top: `calc(50% + ${offsetY}px)`,
|
||||
x: `calc(-50% + ${offsetX}px)`,
|
||||
zIndex: state.options.zIndex,
|
||||
}}
|
||||
initial={{ opacity: 0, y: 8, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, y: 8, filter: 'blur(2px)' }}
|
||||
transition={{ duration: 0.22, ease: 'easeOut' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
'px-30 py-2',
|
||||
// 'bg-linear-to-r from-transparent from-1% via-[#C0945A] via-50% to-transparent to-99% text-white',
|
||||
'whitespace-nowrap text-white',
|
||||
'text-3xl font-semibold tracking-wide',
|
||||
'select-none'
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage('components/提示条'),
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
{state.text}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { hideHint, showHint } from './HintHost'
|
||||
import type { HintPositionOffset, ShowHintOptions } from './HintHost'
|
||||
|
||||
export type SceneHintProps = {
|
||||
/** 是否展示提示 */
|
||||
when: boolean
|
||||
/** 提示文本(建议直接传 string children) */
|
||||
children: string
|
||||
/** 延迟展示(ms) */
|
||||
delayMs?: number
|
||||
/** 自动消失时间(ms)。不传则使用默认值(2000) */
|
||||
durationMs?: number
|
||||
/** 是否常驻 */
|
||||
persistent?: boolean
|
||||
/** 相对中心点微调位置 */
|
||||
offset?: HintPositionOffset
|
||||
/** 自定义层级 */
|
||||
zIndex?: number
|
||||
/** 进入 when=true 时只展示一次(后续 when=true 的 rerender 不会重复触发) */
|
||||
once?: boolean
|
||||
/**
|
||||
* 触发 key:当此值变化时(且 when=true)会重新触发一次 showHint。
|
||||
* 用于“同一个提示需要再次弹出”的场景。
|
||||
*/
|
||||
triggerKey?: string | number
|
||||
/** when 变为 false / 卸载时是否自动 hide */
|
||||
hideOnCleanup?: boolean
|
||||
}
|
||||
|
||||
export function SceneHint({
|
||||
when,
|
||||
children,
|
||||
delayMs = 0,
|
||||
durationMs,
|
||||
persistent = false,
|
||||
offset,
|
||||
zIndex,
|
||||
once = false,
|
||||
triggerKey,
|
||||
hideOnCleanup = true,
|
||||
}: SceneHintProps) {
|
||||
const shownOnceRef = useRef(false)
|
||||
const delayTimerRef = useRef<number | null>(null)
|
||||
|
||||
const text = useMemo(() => {
|
||||
return typeof children === 'string' ? children : String(children ?? '')
|
||||
}, [children])
|
||||
|
||||
useEffect(() => {
|
||||
if (!when) {
|
||||
shownOnceRef.current = false
|
||||
if (hideOnCleanup) hideHint()
|
||||
return
|
||||
}
|
||||
|
||||
if (once && shownOnceRef.current) return
|
||||
|
||||
const options: ShowHintOptions = {
|
||||
persistent,
|
||||
...(durationMs !== undefined ? { durationMs } : null),
|
||||
...(offset ? { offset } : null),
|
||||
...(zIndex !== undefined ? { zIndex } : null),
|
||||
}
|
||||
|
||||
if (delayMs > 0) {
|
||||
delayTimerRef.current = window.setTimeout(() => {
|
||||
showHint(text, options)
|
||||
shownOnceRef.current = true
|
||||
}, delayMs)
|
||||
} else {
|
||||
showHint(text, options)
|
||||
shownOnceRef.current = true
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (delayTimerRef.current) {
|
||||
window.clearTimeout(delayTimerRef.current)
|
||||
delayTimerRef.current = null
|
||||
}
|
||||
if (hideOnCleanup) hideHint()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
when,
|
||||
text,
|
||||
delayMs,
|
||||
durationMs,
|
||||
persistent,
|
||||
offset?.x,
|
||||
offset?.y,
|
||||
zIndex,
|
||||
once,
|
||||
triggerKey,
|
||||
hideOnCleanup,
|
||||
])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { HintHost, showHint, hideHint } from './HintHost'
|
||||
export type { ShowHintOptions, HintPositionOffset } from './HintHost'
|
||||
|
||||
export { SceneHint } from './SceneHint'
|
||||
export type { SceneHintProps } from './SceneHint'
|
||||
+24
-3
@@ -3,8 +3,8 @@ export { default as Video } from './video'
|
||||
export type { VideoProps, VideoRef, VideoAnchor, AnchorContext } from './video'
|
||||
|
||||
// Button components
|
||||
export { Button, ImageButton } from './button'
|
||||
export type { BaseButtonProps, ImageButtonProps } from './button'
|
||||
export { Button, ImageButton, FlipActionButton } from './button'
|
||||
export type { BaseButtonProps, ImageButtonProps, FlipActionButtonProps } from './button'
|
||||
|
||||
// Image component
|
||||
export { Image } from './image'
|
||||
@@ -19,6 +19,8 @@ export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide'
|
||||
// Exhibit overlay
|
||||
export { ExhibitOverlay } from './exhibit'
|
||||
export type { ExhibitOverlayProps } from './exhibit'
|
||||
export { ExhibitFlipOverlay } from './exhibit'
|
||||
export type { ExhibitFlipOverlayProps } from './exhibit'
|
||||
|
||||
// Overlay component
|
||||
export { Overlay } from './overlay'
|
||||
@@ -68,6 +70,12 @@ export type {
|
||||
export { Narration } from './narration'
|
||||
export type { NarrationProps } from './narration'
|
||||
|
||||
// Hint (lightweight toast)
|
||||
export { HintHost, showHint, hideHint } from './hint'
|
||||
export type { ShowHintOptions, HintPositionOffset } from './hint'
|
||||
export { SceneHint } from './hint'
|
||||
export type { SceneHintProps } from './hint'
|
||||
|
||||
// Clue components
|
||||
export { ClueHintModal, ClueBookModal } from './clue'
|
||||
export type { ClueHintModalProps, ClueHint, ClueBookModalProps } from './clue'
|
||||
@@ -81,12 +89,25 @@ export { Select } from './select'
|
||||
export type { SelectProps, SelectOption } from './select'
|
||||
|
||||
// Letter components
|
||||
export { LetterToChenyun, LetterToSunzhongshan, LetterToHuangyanpei, LetterToLuxun } from './letter'
|
||||
export {
|
||||
LetterToChenyun,
|
||||
LetterToMaozedong,
|
||||
LetterToSunzhongshan,
|
||||
LetterToHuangyanpei,
|
||||
LetterToLuxun,
|
||||
LetterToZhangleping,
|
||||
LetterToNieer,
|
||||
LetterToRongdesheng,
|
||||
} from './letter'
|
||||
export type {
|
||||
LetterToChenyunProps,
|
||||
LetterToMaozedongProps,
|
||||
LetterToSunzhongshanProps,
|
||||
LetterToHuangyanpeiProps,
|
||||
LetterToLuxunProps,
|
||||
LetterToZhanglepingProps,
|
||||
LetterToNieerProps,
|
||||
LetterToRongdeshengProps,
|
||||
} from './letter'
|
||||
|
||||
// Puzzle components
|
||||
|
||||
@@ -15,7 +15,13 @@ import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
|
||||
* 支持拼图盒功能的故居列表
|
||||
* 后续如需为其他故居添加拼图盒,只需在此数组中添加对应的故居名称即可
|
||||
*/
|
||||
const RESIDENCES_WITH_PUZZLE = ['chenyun', 'huangyanpei', 'sunzhongshan', 'luxun'] as const
|
||||
const RESIDENCES_WITH_PUZZLE = [
|
||||
'chenyun',
|
||||
'huangyanpei',
|
||||
'sunzhongshan',
|
||||
'luxun',
|
||||
'maozedong',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
|
||||
@@ -9,7 +9,7 @@ export { default as SuccessAlert } from './alert/success-alert'
|
||||
export type { SuccessAlertProps } from './alert/success-alert'
|
||||
export { default as ErrorAlert } from './alert/error-alert'
|
||||
export type { ErrorAlertProps } from './alert/error-alert'
|
||||
export { DragQuizPanel } from './quiz'
|
||||
export { DragQuizPanel, BasketQuizPanel } from './quiz'
|
||||
export type { DragQuizPanelProps } from './quiz'
|
||||
// export type {
|
||||
// ClueData,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DragFill } from '@/components'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DragQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* BasketQuizPanel - 篮子拖拽题面板
|
||||
*
|
||||
* 左右布局:左边篮子放置区,右边选项列表(每个选项单独一行)
|
||||
*/
|
||||
const BasketQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
questionPrefix,
|
||||
questionSuffix,
|
||||
options,
|
||||
blanks,
|
||||
value,
|
||||
error,
|
||||
feedbackType = 'error',
|
||||
onChange,
|
||||
}: DragQuizPanelProps) => {
|
||||
const prevVisibleRef = useRef(visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevVisibleRef.current && !visible && value.length > 0) {
|
||||
onChange([])
|
||||
}
|
||||
prevVisibleRef.current = visible
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const filledValues = new Set(value.map((r) => r.optionValue))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center pt-12 pb-14">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black/90 px-20 py-12 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[32px] leading-relaxed text-white">
|
||||
{title && <div className="mb-4">{title}</div>}
|
||||
<div>
|
||||
{questionPrefix && <span>{questionPrefix}</span>}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
{error && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-5 text-center text-[26px]',
|
||||
feedbackType === 'success' ? 'text-[#b7f7d1]' : 'text-[#fcbe53]'
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 左右布局:篮子 + 选项 */}
|
||||
<div className="w-[1700px] mt-10 flex gap-10 flex-1 min-h-0">
|
||||
{/* 左侧:篮子区域 - 每个 blank 都需要 data-blank-id 以支持多选项放入 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-[800px] rounded-[32px]',
|
||||
'border-4 border-dashed border-[#d4c5a9]/60',
|
||||
'bg-white/5 backdrop-blur-sm',
|
||||
'flex flex-wrap content-center items-center justify-center gap-4 px-8 py-6',
|
||||
'transition-all duration-300'
|
||||
)}
|
||||
>
|
||||
{/* 隐藏的 blank 锚点,让每个 blank 都能被 DragFill 检测到 */}
|
||||
{blanks.map((blank) => {
|
||||
const isFilled = value.some((r) => r.blankId === blank.id)
|
||||
if (isFilled) return null
|
||||
return (
|
||||
<div
|
||||
key={blank.id}
|
||||
data-blank-id={blank.id}
|
||||
className="absolute inset-0 rounded-[32px]"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{value.length === 0 ? (
|
||||
<span className="text-white/30 text-[24px]">请将正确选项拖入此处</span>
|
||||
) : (
|
||||
value.map((result) => {
|
||||
const opt = options.find((o) => o.value === result.optionValue)
|
||||
return (
|
||||
<div
|
||||
key={result.blankId + '-' + result.optionValue}
|
||||
className={cn(
|
||||
'relative px-8 py-4 flex items-center justify-center',
|
||||
'rounded-[24px] border-3 border-[#f0be75]',
|
||||
'bg-linear-to-br from-[#4a3f35] to-[#3a2f25]',
|
||||
'text-[24px] font-bold text-[#f5e6d3]',
|
||||
'shadow-[0_6px_24px_rgba(240,190,117,0.3)]',
|
||||
'gap-4 animate-in fade-in zoom-in duration-300'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-[24px] bg-linear-to-br from-[#d4c5a9]/15 to-transparent pointer-events-none" />
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
|
||||
{opt?.label || result.optionValue}
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="relative z-10 w-7 h-7 flex items-center justify-center rounded-full bg-[#f0be75]/20 hover:bg-[#f0be75]/40 text-[#f0be75] text-sm transition-all hover:scale-110"
|
||||
onClick={() => {
|
||||
onChange(value.filter((r) => r.blankId !== result.blankId))
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:选项列表,每个选项单独一行 */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
hideLabels
|
||||
showSubmitButton={false}
|
||||
layout="vertical"
|
||||
className="w-full h-full"
|
||||
optionClassName="flex flex-col gap-6"
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[90px] px-10 flex items-center justify-center',
|
||||
'rounded-[32px] transition-all duration-300',
|
||||
'text-[28px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
|
||||
isUsed || filledValues.has(option.value)
|
||||
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
|
||||
: 'border-[#d4c5a9] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)] hover:scale-[1.02]'
|
||||
)}
|
||||
>
|
||||
{!(isUsed || filledValues.has(option.value)) && (
|
||||
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] text-center leading-snug">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
renderDragging={(option) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[60px] px-10 flex items-center justify-center',
|
||||
'rounded-[32px]',
|
||||
'text-[20px] font-bold border-3 shadow-[0_16px_48px_rgba(240,190,117,0.6)]',
|
||||
'border-[#f0be75] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3]',
|
||||
'scale-105 opacity-90'
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] text-center leading-snug whitespace-nowrap">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
renderBlank={() => <div className="hidden" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasketQuizPanel
|
||||
@@ -12,6 +12,7 @@ const DragQuizPanel = ({
|
||||
questionSuffix,
|
||||
options,
|
||||
blanks,
|
||||
blankPrefixes,
|
||||
blankSeparators,
|
||||
value,
|
||||
error,
|
||||
@@ -47,6 +48,7 @@ const DragQuizPanel = ({
|
||||
const filledValue = filledResult?.optionValue || null
|
||||
return (
|
||||
<span key={blank.id}>
|
||||
{blankPrefixes?.[index] && <span>{blankPrefixes[index]}</span>}
|
||||
<span
|
||||
data-blank-id={blank.id}
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { MatchQuizPanelProps } from './types'
|
||||
|
||||
@@ -45,8 +45,17 @@ const MatchQuizPanel = ({
|
||||
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 leftItemRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
const rightItemRefs = 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)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
|
||||
// 当窗口关闭时,重置状态
|
||||
useEffect(() => {
|
||||
@@ -163,17 +172,79 @@ const MatchQuizPanel = ({
|
||||
return matches.some((m) => m.rightId === rightId)
|
||||
}
|
||||
|
||||
// 获取左项对应的右项ID
|
||||
const getMatchedRightId = (leftId: string) => {
|
||||
const match = matches.find((m) => m.leftId === leftId)
|
||||
return match?.rightId || null
|
||||
}
|
||||
const measureCenters = useCallback(() => {
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
|
||||
// 获取右项对应的左项ID
|
||||
const getMatchedLeftId = (rightId: string) => {
|
||||
const match = matches.find((m) => m.rightId === rightId)
|
||||
return match?.leftId || null
|
||||
}
|
||||
const middleRect = middleEl.getBoundingClientRect()
|
||||
const nextLeft: Record<string, number> = {}
|
||||
const nextRight: Record<string, number> = {}
|
||||
|
||||
// 显式同步中间连线层高度,避免因 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)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
}, [measureCenters])
|
||||
|
||||
// 首次展示、匹配变化、选中/错误状态变化都会影响位置(含动画/缩放),延后一帧再测
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
scheduleMeasure()
|
||||
}, [visible, matches.length, errorPairs.length, selectedLeftId, scheduleMeasure])
|
||||
|
||||
// 监听尺寸变化(窗口 resize / 容器尺寸变化 / 文本换行)
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const middleEl = middleRef.current
|
||||
if (!middleEl) return
|
||||
|
||||
const handleResize = () => scheduleMeasure()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
const ro = new ResizeObserver(() => scheduleMeasure())
|
||||
ro.observe(middleEl)
|
||||
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)
|
||||
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [visible, scheduleMeasure])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
@@ -199,9 +270,9 @@ 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-center justify-center">
|
||||
<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 className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
<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
|
||||
@@ -210,6 +281,9 @@ const MatchQuizPanel = ({
|
||||
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',
|
||||
@@ -221,7 +295,7 @@ const MatchQuizPanel = ({
|
||||
? '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-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
: '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) => {
|
||||
@@ -249,7 +323,11 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
|
||||
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
|
||||
<div className="hidden md:block w-16 h-full min-h-[300px] relative flex-shrink-0">
|
||||
<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) => {
|
||||
@@ -257,14 +335,9 @@ const MatchQuizPanel = ({
|
||||
const rightItem = rightItems.find((item) => item.id === match.rightId)
|
||||
if (!leftItem || !rightItem) return null
|
||||
|
||||
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId)
|
||||
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId)
|
||||
|
||||
// 计算连线起点和终点(基于项目高度和间距)
|
||||
const itemHeight = 80 // 每个项目的大概高度
|
||||
const gap = 16 // 项目之间的间距
|
||||
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2
|
||||
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2
|
||||
const startY = leftCenters[match.leftId]
|
||||
const endY = rightCenters[match.rightId]
|
||||
if (startY === undefined || endY === undefined) return null
|
||||
|
||||
return (
|
||||
<line
|
||||
@@ -297,7 +370,7 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
|
||||
{/* 右列:事件 */}
|
||||
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
|
||||
<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)
|
||||
@@ -305,6 +378,9 @@ const MatchQuizPanel = ({
|
||||
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',
|
||||
@@ -319,7 +395,7 @@ const MatchQuizPanel = ({
|
||||
: isMatched
|
||||
? 'bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]'
|
||||
: selectedLeftId
|
||||
? 'bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]'
|
||||
? '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)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SingleChoice } from '@/components'
|
||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||
import type { SingleChoiceQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
@@ -38,20 +39,38 @@ const SingleChoiceQuizPanel = ({
|
||||
onWrongSelect,
|
||||
onError,
|
||||
disabled = false,
|
||||
showSuccessAlert = false,
|
||||
successAlertProps,
|
||||
onSuccessContinue,
|
||||
}: SingleChoiceQuizPanelProps) => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>('')
|
||||
const [error, setError] = useState<string>('')
|
||||
const [successAlertVisible, setSuccessAlertVisible] = useState<boolean>(false)
|
||||
|
||||
// 当窗口关闭时,重置内部状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
setSuccessAlertVisible(false)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 当成功弹窗显示时,隐藏题目面板,避免双层遮罩叠加
|
||||
const shouldRenderQuizPanel = visible && !successAlertVisible
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
// 约定:显式开启,或传入 title/content 任一文案,即启用答对成功弹窗
|
||||
const hasSuccessAlertCopy =
|
||||
Boolean(successAlertProps?.title) || Boolean(successAlertProps?.content)
|
||||
const shouldShowSuccessAlert = showSuccessAlert || hasSuccessAlertCopy
|
||||
|
||||
const handleSuccessContinue = () => {
|
||||
setSuccessAlertVisible(false)
|
||||
onSuccessContinue?.()
|
||||
}
|
||||
|
||||
// 处理选项点击
|
||||
const handleOptionClick = (value: string) => {
|
||||
if (disabled) return
|
||||
@@ -69,6 +88,10 @@ const SingleChoiceQuizPanel = ({
|
||||
setSelectedValue('')
|
||||
setError('')
|
||||
onSubmit?.(value, true)
|
||||
|
||||
if (shouldShowSuccessAlert) {
|
||||
setSuccessAlertVisible(true)
|
||||
}
|
||||
} else {
|
||||
// 答题失败:清空状态,执行 onError,允许重新选择
|
||||
setSelectedValue('')
|
||||
@@ -86,45 +109,61 @@ const SingleChoiceQuizPanel = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/40" style={{ zIndex }}>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
<>
|
||||
{shouldRenderQuizPanel && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<div className="relative w-[1728px] h-[972px] flex flex-col items-center justify-center">
|
||||
{/* 题干卡片 */}
|
||||
<div className="w-[1700px] rounded-[32px] bg-black px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9]">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{/* 答案反馈 */}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
{/* 答案反馈 */}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
disabled={disabled}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? 'bg-[#d4a574] text-[#5c3e2e]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:bg-gradient-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{/* 选项区域 */}
|
||||
<div className="mt-16">
|
||||
<SingleChoice
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
disabled={disabled}
|
||||
renderOption={(option, isSelected) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="mb-6 cursor-pointer"
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<div
|
||||
className={`w-[1500px] flex items-center p-4 text-[28px] leading-relaxed rounded-tr-[50px] rounded-bl-4xl transition-all duration-150 ${
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
|
||||
} ${
|
||||
isSelected
|
||||
? 'bg-[#d4a574] text-[#5c3e2e]'
|
||||
: 'bg-[#d7b888] text-[#5c3e2e] hover:bg-linear-to-r from-[#ddf3e1] to-[#fbdd97]'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowSuccessAlert && (
|
||||
<SuccessAlert
|
||||
{...successAlertProps}
|
||||
visible={successAlertVisible}
|
||||
onContinue={handleSuccessContinue}
|
||||
onClose={handleSuccessContinue}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export { default as SingleChoiceQuizPanel } from './SingleChoiceQuizPanel'
|
||||
export { default as MultipleChoiceQuizPanel } from './MultipleChoiceQuizPanel'
|
||||
export { default as DragQuizPanel } from './DragQuizPanel'
|
||||
export { default as BasketQuizPanel } from './BasketQuizPanel'
|
||||
export { default as MatchQuizPanel } from './MatchQuizPanel'
|
||||
export { default as SelectFillQuizPanel } from './SelectFillQuizPanel'
|
||||
export type {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DragFillResult,
|
||||
DragFillBlank,
|
||||
} from '@/components'
|
||||
import type { SuccessAlertProps } from '@/components/layout/alert/success-alert'
|
||||
|
||||
/** 拖拽题面板配置 */
|
||||
export interface DragQuizPanelProps {
|
||||
@@ -21,12 +22,19 @@ export interface DragQuizPanelProps {
|
||||
options: { value: string; label: string }[]
|
||||
/** 空白区域列表 */
|
||||
blanks: DragFillBlank[]
|
||||
/** 每个空之前的前缀文本(按 blanks 顺序一一对应) */
|
||||
blankPrefixes?: string[]
|
||||
/** 空白之间的分隔符数组(可选,如果不提供则使用默认的"、") */
|
||||
blankSeparators?: string[]
|
||||
/** 当前值 */
|
||||
value: DragFillResult[]
|
||||
/** 错误提示信息 */
|
||||
/**
|
||||
* 提示信息(历史上叫 error,但实际也可能用来展示正确提示)
|
||||
* 由 feedbackType 控制样式
|
||||
*/
|
||||
error?: string
|
||||
/** 提示类型:success 为正确提示,error 为错误提示 */
|
||||
feedbackType?: 'success' | 'error'
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void
|
||||
}
|
||||
@@ -53,6 +61,12 @@ export interface SingleChoiceQuizPanelProps {
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 答对后是否自动显示 SuccessAlert(默认 false;当 successAlertProps.title/content 有值时也会自动显示) */
|
||||
showSuccessAlert?: boolean
|
||||
/** SuccessAlert 的可配置项(除 visible / onContinue / onClose 外) */
|
||||
successAlertProps?: Omit<SuccessAlertProps, 'visible' | 'onContinue' | 'onClose'>
|
||||
/** 答对弹窗点击继续回调 */
|
||||
onSuccessContinue?: () => void
|
||||
}
|
||||
|
||||
/** 多选题面板配置 */
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getPublicPath, getStaticImage } from '@/lib/utils'
|
||||
import { filterInput } from '@/lib/utils/sensitiveWords'
|
||||
import { Image, Select, Video, type VideoRef } from '@/components'
|
||||
import { showHint } from '@/components/hint'
|
||||
|
||||
export type LetterFieldType = 'text' | 'input' | 'select'
|
||||
|
||||
export interface LetterFieldOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface LetterFieldHintConfig {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface LetterFieldConfig {
|
||||
type?: LetterFieldType
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
options?: LetterFieldOption[]
|
||||
fitContent?: boolean
|
||||
widthClassName?: string
|
||||
className?: string
|
||||
readOnlyFallback?: string
|
||||
sanitize?: boolean
|
||||
correctValue?: string
|
||||
hints?: LetterFieldHintConfig
|
||||
fontSize?: number
|
||||
}
|
||||
|
||||
export interface LetterTemplateProps {
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
title: string
|
||||
letterContent: string
|
||||
fields?:
|
||||
| Record<number, LetterFieldConfig>
|
||||
| ((values: string[]) => Record<number, LetterFieldConfig>)
|
||||
validate?: (values: string[], letterContent: string) => boolean[]
|
||||
contentClassName?: string
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
sceneImageSrc?: string
|
||||
replyButtonImageSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const DEFAULT_TEXT_FIELD: LetterFieldConfig = {
|
||||
type: 'text',
|
||||
maxLength: 10,
|
||||
widthClassName: 'w-[100px]',
|
||||
sanitize: true,
|
||||
}
|
||||
|
||||
const getPlaceholderCount = (letterContent: string) => (letterContent.match(/____/g) || []).length
|
||||
|
||||
const LetterTemplate = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
title,
|
||||
letterContent,
|
||||
fields = {},
|
||||
validate,
|
||||
contentClassName,
|
||||
audioSrc,
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
sceneImageSrc = 'components/letter/动画结束后场景',
|
||||
replyButtonImageSrc = 'components/letter/点击敬读',
|
||||
replyImageSrc,
|
||||
}: LetterTemplateProps) => {
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showSendVideo, setShowSendVideo] = useState(false)
|
||||
const [showScene, setShowScene] = useState(false)
|
||||
const [showReplyImage, setShowReplyImage] = useState(false)
|
||||
const sendVideoRef = useRef<VideoRef>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() => (typeof fields === 'function' ? fields(blankValues) : fields),
|
||||
[fields, blankValues]
|
||||
)
|
||||
|
||||
const validateBlanks = () => {
|
||||
const placeholderCount = getPlaceholderCount(letterContent)
|
||||
const nextValues = Array.from(
|
||||
{ length: placeholderCount },
|
||||
(_, index) => blankValues[index] ?? ''
|
||||
)
|
||||
|
||||
const nextErrors = validate
|
||||
? validate(nextValues, letterContent)
|
||||
: nextValues.map((value, index) => {
|
||||
const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD
|
||||
const trimmedValue = value.trim()
|
||||
const maxLength = field.maxLength ?? 10
|
||||
|
||||
if (!trimmedValue || trimmedValue.length > maxLength) {
|
||||
if (!trimmedValue) {
|
||||
showHint('请填写完整后再提交哦!')
|
||||
} else {
|
||||
showHint(`内容过长,请不要超过${maxLength}个字哦!`)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (field.correctValue && trimmedValue !== field.correctValue) {
|
||||
if (field.hints?.error) {
|
||||
showHint(field.hints.error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const hasError = nextErrors.some(Boolean)
|
||||
if (hasError) {
|
||||
// 清空所有错误位置的输入
|
||||
const cleanedValues = [...blankValues]
|
||||
nextErrors.forEach((isError, index) => {
|
||||
if (isError) {
|
||||
cleanedValues[index] = ''
|
||||
}
|
||||
})
|
||||
setBlankValues(cleanedValues)
|
||||
}
|
||||
|
||||
setBlankErrors(nextErrors)
|
||||
return !hasError
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setShowSendVideo(false)
|
||||
setShowScene(false)
|
||||
setShowReplyImage(false)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 音频播放逻辑
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audioSrc) return
|
||||
|
||||
if (visible && !showSendVideo) {
|
||||
audio.currentTime = 0
|
||||
audio.play().catch(() => {})
|
||||
} else {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}, [visible, showSendVideo, audioSrc])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
return () => {
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateBlank = (index: number, value: string) => {
|
||||
const field = resolvedFields[index] ?? DEFAULT_TEXT_FIELD
|
||||
const nextValue = field.sanitize === false ? value : filterInput(value)
|
||||
const nextValues = [...blankValues]
|
||||
nextValues[index] = nextValue
|
||||
setBlankValues(nextValues)
|
||||
|
||||
// correctValue 字段:输入过程中不标红,由 blur / select 触发校验;
|
||||
// 若当前已标红且值匹配正确答案,则清除标红
|
||||
if (field.correctValue) {
|
||||
if (nextValue.trim() === field.correctValue && blankErrors[index]) {
|
||||
const nextErrors = [...blankErrors]
|
||||
nextErrors[index] = false
|
||||
setBlankErrors(nextErrors)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 无 correctValue 字段:实时校验空 / 超长
|
||||
const trimmedValue = nextValue.trim()
|
||||
const maxLength = field.maxLength ?? 10
|
||||
const isError = !trimmedValue || trimmedValue.length > maxLength
|
||||
if (blankErrors[index] !== isError) {
|
||||
const nextErrors = [...blankErrors]
|
||||
nextErrors[index] = isError
|
||||
setBlankErrors(nextErrors)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectChange = (index: number, value: string) => {
|
||||
updateBlank(index, value)
|
||||
|
||||
const field = resolvedFields[index]
|
||||
if (!field?.correctValue) return
|
||||
|
||||
if (value !== field.correctValue) {
|
||||
if (field.hints?.error) {
|
||||
showHint(field.hints.error)
|
||||
}
|
||||
|
||||
const nextValues = [...blankValues]
|
||||
nextValues[index] = ''
|
||||
setBlankValues(nextValues)
|
||||
|
||||
const nextErrors = [...blankErrors]
|
||||
nextErrors[index] = true
|
||||
setBlankErrors(nextErrors)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = (index: number) => {
|
||||
const field = resolvedFields[index]
|
||||
if (!field?.correctValue) return
|
||||
|
||||
const value = (blankValues[index] ?? '').trim()
|
||||
if (value && value !== field.correctValue) {
|
||||
if (field.hints?.error) {
|
||||
showHint(field.hints.error)
|
||||
}
|
||||
|
||||
const nextValues = [...blankValues]
|
||||
nextValues[index] = ''
|
||||
setBlankValues(nextValues)
|
||||
|
||||
const nextErrors = [...blankErrors]
|
||||
nextErrors[index] = true
|
||||
setBlankErrors(nextErrors)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendVideoCanPlay = () => {
|
||||
if (showSendVideo && sendVideoRef.current) {
|
||||
sendVideoRef.current.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const renderLine = (readonly: boolean) => {
|
||||
let placeholderIndex = 0
|
||||
let bodyStarted = false
|
||||
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
const trimmedLine = line.trim()
|
||||
const isSalutationLine = trimmedLine.endsWith(':') || trimmedLine.endsWith(':')
|
||||
|
||||
if (isSalutationLine) {
|
||||
bodyStarted = true
|
||||
}
|
||||
|
||||
const needsIndent = bodyStarted && trimmedLine !== '' && !isSalutationLine
|
||||
const segments = line.split('____')
|
||||
const linePrefix = needsIndent ? ' ' : ''
|
||||
|
||||
return (
|
||||
<div key={lineIndex} className="">
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const field = resolvedFields[currentIndex] ?? DEFAULT_TEXT_FIELD
|
||||
const fieldValue = blankValues[currentIndex] ?? ''
|
||||
|
||||
if (readonly) {
|
||||
const displayValue =
|
||||
field.type === 'select'
|
||||
? field.options?.find((option) => option.value === fieldValue)?.label ||
|
||||
fieldValue
|
||||
: fieldValue
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||||
{displayValue || field.readOnlyFallback || ' '}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||||
<Select
|
||||
className={field.className || 'inline'}
|
||||
options={field.options ?? []}
|
||||
value={fieldValue}
|
||||
onChange={(value) => handleSelectChange(currentIndex, value)}
|
||||
placeholder={field.placeholder || '请选择'}
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={field.fitContent ?? true}
|
||||
fontSize={field.fontSize}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{partIndex === 0 ? `${linePrefix}${part}` : part}
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-center outline-none ${field.widthClassName || 'w-[100px]'} ${field.className || ''} ${blankErrors[currentIndex] ? 'border-red-500' : 'border-[#4e402e]'}`}
|
||||
style={{ fontSize: field.fontSize ? `${field.fontSize}px` : '26px' }}
|
||||
autoComplete="off"
|
||||
value={fieldValue}
|
||||
maxLength={field.maxLength ?? 10}
|
||||
onChange={(e) => updateBlank(currentIndex, e.target.value)}
|
||||
onBlur={() => handleInputBlur(currentIndex)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<>
|
||||
{audioSrc && (
|
||||
<audio ref={audioRef} src={getPublicPath(audioSrc)} loop className="hidden" />
|
||||
)}
|
||||
|
||||
{!showSendVideo && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-100"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSendVideo && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-101 flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:0px_0px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921,6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div
|
||||
className={`text-[#4e402e] text-[26px] leading-relaxed ${contentClassName || ''}`}
|
||||
>
|
||||
{renderLine(false)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="关闭"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div
|
||||
className={`text-[#4e402e] text-[26px] leading-relaxed ${contentClassName || ''}`}
|
||||
>
|
||||
{renderLine(true)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
const nextValues = [...blankValues]
|
||||
onSubmit?.(nextValues)
|
||||
setShowSendVideo(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showVideo && showSendVideo && (
|
||||
<motion.div
|
||||
key="video"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="fixed inset-0 z-101 h-full w-full"
|
||||
>
|
||||
<Video
|
||||
ref={sendVideoRef}
|
||||
src={getPublicPath(videoSrc)}
|
||||
className="h-full w-full"
|
||||
playsInline
|
||||
onCanPlay={handleSendVideoCanPlay}
|
||||
onEnded={() => {
|
||||
setShowSendVideo(false)
|
||||
setShowScene(true)
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{showScene && !showReplyImage && replyImageSrc && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-102"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<img
|
||||
src={getStaticImage(sceneImageSrc)}
|
||||
alt="场景背景"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<Image
|
||||
className="absolute bottom-170 left-222"
|
||||
src={getStaticImage('components/letter/回信点击')}
|
||||
alt="回信点击"
|
||||
onClick={() => setShowReplyImage(true)}
|
||||
button
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-130 left-222"
|
||||
animate={{
|
||||
y: [-10, 10, -10],
|
||||
scale: [0.95, 1.05, 0.95],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'loop',
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<Image src={getStaticImage(replyButtonImageSrc)} alt="提示点击手指" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{showReplyImage && replyImageSrc && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-103 flex items-center justify-center bg-black/80"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.img
|
||||
src={getStaticImage(replyImageSrc)}
|
||||
alt="回信"
|
||||
className="w-full h-full object-contain"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="关闭"
|
||||
className="absolute top-0 right-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setShowReplyImage(false)
|
||||
setShowScene(false)
|
||||
onClose?.()
|
||||
onAnimationEnd?.()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterTemplate
|
||||
@@ -1,68 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image } from '@/components'
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToChenyunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToChenyun 组件 - 寄给陈云爷爷的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 校验输入:不能为空、每个不超过 10 个字符
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = blankValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
// 为空 或 超过 10 个字,都视为不符合要求:清空并标红
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的陈云爷爷:
|
||||
const letterContent = `敬爱的陈云爷爷:
|
||||
您好!
|
||||
|
||||
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于上海市青浦区练塘镇的陈云纪念馆,学习了您“不唯上、不唯书、只唯____”的做事准则,还有“综合平衡”“人民至上”的经济思想。我深刻体会到,这些精神不仅是建设年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
|
||||
@@ -71,250 +20,63 @@ const LetterToChenyun = ({ visible = false, onClose, onSubmit }: LetterToChenyun
|
||||
|
||||
站在新时代的起点,我一定以____为志向,勤学苦练,传承精神,让您的务实担当在青春里扎根,让奋斗在建设祖国的实践中绽放光彩!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '实践',
|
||||
},
|
||||
1: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '勤学钻研',
|
||||
},
|
||||
2: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '人民利益',
|
||||
},
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '专业',
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '实干报国',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToChenyun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/8如愿.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-陈云',
|
||||
}: LetterToChenyunProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-[100]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 信件容器 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
寄给陈云的信
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 背面:只读态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 信件内容区域(只读) */}
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-6"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给陈云的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,396 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
import { filterInput } from '@/lib/utils/sensitiveWords'
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToHuangyanpeiProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToHuangyanpei 组件 - 写给黄炎培先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToHuangyanpei = ({ visible = false, onClose, onSubmit }: LetterToHuangyanpeiProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 填空类型定义:0=填空1(限填),1=填空2(任填),2=填空3(任填),3=填空4(限填)
|
||||
const BLANK_1_ANSWER = '手脑并用'
|
||||
const BLANK_4_ANSWER = '有业者乐业'
|
||||
|
||||
// 填空1的选项
|
||||
const BLANK_1_OPTIONS = [
|
||||
'手脑并用',
|
||||
'有业者乐业',
|
||||
// "知行合一",
|
||||
// "学以致用",
|
||||
// "理论实践"
|
||||
]
|
||||
|
||||
// 填空4的选项
|
||||
const BLANK_4_OPTIONS = [
|
||||
'手脑并用',
|
||||
'有业者乐业',
|
||||
// "有业者敬业",
|
||||
// "有业者精业",
|
||||
// "有业者成业",
|
||||
]
|
||||
|
||||
// 校验输入:确保所有填空符合标准
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = nextValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i === 0) {
|
||||
// 填空1:限填,必须选择"手脑并用"
|
||||
if (!v || v !== BLANK_1_ANSWER) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 1 || i === 2) {
|
||||
// 填空2、3:任填,不能为空,不超过20个字
|
||||
if (!v || v.length > 20) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 3) {
|
||||
// 填空4:限填,必须选择"有业者乐业"
|
||||
if (!v || v !== BLANK_4_ANSWER) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的黄炎培先生:
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '手脑并用', label: '手脑并用' },
|
||||
{ value: '有业者乐业', label: '有业者乐业' },
|
||||
],
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的黄炎培先生:
|
||||
您好!
|
||||
|
||||
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校"手脑并用"的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
|
||||
今天,我循着您的足迹,走进了川沙小学堂的新式课堂,亲历了中华职业学校“手脑并用”的实训时光。那些您为破除旧教育桎梏的奔走、为推广职业教育的坚守、为支援国家危亡的热忱,不再是书本上的文字,而是可感可触的精神力量,深深烙印在我心中。
|
||||
|
||||
我终于明白,您喊出的"双手万能、____",不是一句口号,而是我们的立身之本;您践行的"教育救国、实业报国",不是一段历史,而是我们这代人接续的使命;您坚守的"因时求变",不是一种选择,而是我们面对未来的底气。
|
||||
我终于明白,您喊出的“双手万能、____”,不是一句口号,而是我们的立身之本;您践行的“教育救国、实业报国”,不是一段历史,而是我们这代人接续的使命;您坚守的“因时求变”,不是一种选择,而是我们面对未来的底气。
|
||||
|
||||
我是____专业的学生,今后,我会在实训课堂上精益求精,像当年中华职校的学长学姐那样,把技能练到极致;我会在志愿服务中践行担当,用所学知识为身边人、为社会做实事;我更会紧跟时代步伐,努力成为____领域的技能人才,为中国制造升级、为民族产业振兴添砖加瓦!
|
||||
|
||||
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的"无业者有业、____"的愿景,我们会用青春和汗水去实现!`
|
||||
请您放心,您播下的职教火种,我们会让它在新时代燃烧得更旺;您毕生追求的“使无业者有业,使____”的愿景,我们会用青春和汗水去实现!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
...commonSelectField,
|
||||
correctValue: '手脑并用',
|
||||
hints: {
|
||||
error: '“双手万能、手脑并用”是黄炎培职业教育思想的核心理念之一。',
|
||||
},
|
||||
readOnlyFallback: '手脑并用',
|
||||
},
|
||||
1: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '',
|
||||
},
|
||||
2: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '',
|
||||
},
|
||||
3: {
|
||||
...commonSelectField,
|
||||
correctValue: '有业者乐业',
|
||||
hints: {
|
||||
error: '黄炎培把职业教育的目的概括为“使无业者有业,使有业者乐业”。',
|
||||
},
|
||||
readOnlyFallback: '有业者乐业',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToHuangyanpei = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/4不忘初心.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-黄炎培',
|
||||
}: LetterToHuangyanpeiProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-[100]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 信件容器 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给黄炎培先生的信
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const isLimitedFill = currentIndex === 0 || currentIndex === 3 // 填空1和填空4为限填
|
||||
const options =
|
||||
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_4_OPTIONS
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入,限制20个字,敏感词过滤
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
autoComplete="off"
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
maxLength={20}
|
||||
onChange={(e) => {
|
||||
const rawValue = e.target.value
|
||||
// 应用敏感词过滤
|
||||
const filteredValue = filterInput(rawValue)
|
||||
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = filteredValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 背面:只读态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 信件内容区域(只读) */}
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div key={lineIndex} style={needsIndent ? { textIndent: '2em' } : {}}>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
// 寄出前再次校验,确保所有填空符合标准
|
||||
if (validateBlanks()) {
|
||||
onSubmit?.(blankValues)
|
||||
}
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给黄炎培的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
contentClassName="!text-[25px]"
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,397 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToLuxunProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToLuxun 组件 - 写给鲁迅先生的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToLuxun = ({ visible = false, onClose, onSubmit }: LetterToLuxunProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '旨在立人', label: '旨在立人' },
|
||||
{ value: '批判求真', label: '批判求真' },
|
||||
],
|
||||
}
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// 填空类型定义:0=填空1(限填),1=填空2(限填),其他=任填
|
||||
const BLANK_1_ANSWER = '首在立人'
|
||||
const BLANK_2_ANSWER = '批判求真'
|
||||
|
||||
// 填空1的选项
|
||||
const BLANK_1_OPTIONS = [
|
||||
'首在立人',
|
||||
'批判求真',
|
||||
// 可以添加其他选项
|
||||
]
|
||||
|
||||
// 填空2的选项
|
||||
const BLANK_2_OPTIONS = [
|
||||
'首在立人',
|
||||
'批判求真',
|
||||
// 可以添加其他选项
|
||||
]
|
||||
|
||||
// 校验输入:确保所有填空符合标准
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = nextValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i === 0) {
|
||||
// 填空1:限填,必须选择"首在立人"
|
||||
if (!v || v !== BLANK_1_ANSWER) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === 1) {
|
||||
// 填空2:限填,必须选择"批判求真"
|
||||
if (!v || v !== BLANK_2_ANSWER) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他填空:任填,不能为空,不超过 10 个字
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的鲁迅先生:
|
||||
const letterContent = `敬爱的鲁迅先生:
|
||||
|
||||
您好!
|
||||
|
||||
今天,我怀着崇敬的心情走进大陆新村9号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您"吾将上下而求索"的身影。在这场跨越时空的对话中,我终于明白您坚守的"____",不是抽象的理念,而是我们安身立命的根本;您秉持的"____",不是无谓的锋芒,而是我们直面现实的勇气。
|
||||
今天,我怀着崇敬的心情走进大陆新村 9 号,看着您亲手撰写的《呐喊》《且介亭杂文》《故事新编》等作品,您编辑的《萌芽》《前哨》等杂志,您翻译的《艺术论》《死魂灵》等译文,仿佛能看到您“吾将上下而求索”的身影。在这场跨越时空的对话中,我终于明白您坚守的“____”,不是抽象的理念,而是我们安身立命的根本;您秉持的“____”,不是无谓的锋芒,而是我们直面现实的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您"____千夫指"的骨气,在是非面前坚守立场,不随波逐流;我会以您"俯首甘为____"的热忱,参与____活动,用微光温暖他人;我更会记住您"____,走的人多了,便成了路"的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
我是____专业的学生,今后,我会以您“____于夫指”的骨气,在是非面前坚守立场,不随波逐流;我会以您“俯首甘为____”的热忱,参与____活动,用微光温暖他人;我更会记住您“____,走的人多了,也便成了路”的箴言,在专业领域勇于探索,努力用____为民族复兴、文化繁荣添砖加瓦。
|
||||
|
||||
请您放心,您播下的"立人"火种,我们会让它在新时代燎原;您毕生追求的"人国"愿景,我们会用青春和奋斗去守护!`
|
||||
请您放心,您播下的“立人”火种,我们会让它在新时代燎原;您毕生追求的“人国”愿景,我们会用青春和奋斗去守护!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
...commonSelectField,
|
||||
correctValue: '旨在立人',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '旨在立人',
|
||||
},
|
||||
1: {
|
||||
...commonSelectField,
|
||||
correctValue: '批判求真',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
},
|
||||
readOnlyFallback: '批判求真',
|
||||
},
|
||||
2: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '中文',
|
||||
},
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
correctValue: '横眉冷对',
|
||||
readOnlyFallback: '横眉冷对',
|
||||
hints: {
|
||||
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
|
||||
},
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
correctValue: '孺子牛',
|
||||
readOnlyFallback: '孺子牛',
|
||||
hints: {
|
||||
error: '“横眉冷对于夫指,俯首甘为孺子牛”出自鲁迅先生的《自嘲》一诗。',
|
||||
},
|
||||
},
|
||||
5: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '志愿服务',
|
||||
},
|
||||
6: {
|
||||
type: 'input',
|
||||
maxLength: 16,
|
||||
widthClassName: 'w-[120px]',
|
||||
correctValue: '世上本没有路',
|
||||
readOnlyFallback: '世上本没有路',
|
||||
hints: {
|
||||
error: '鲁迅在小说《故乡》中写道:“地上本没有路,走的人多了,也便成了路。”',
|
||||
},
|
||||
},
|
||||
7: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '专业所长',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToLuxun = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/3我和我的祖国.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-鲁迅',
|
||||
}: LetterToLuxunProps) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-[100]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 信件容器 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给鲁迅先生的信
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const isLimitedFill = currentIndex === 0 || currentIndex === 1 // 填空1和填空2为限填
|
||||
const options =
|
||||
currentIndex === 0 ? BLANK_1_OPTIONS : BLANK_2_OPTIONS
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isLimitedFill ? (
|
||||
// 限填:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={options.map((opt) => ({
|
||||
value: opt,
|
||||
label: opt,
|
||||
}))}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次选择时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 任填:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[100px] h-[36px] mx-2 mb-2 align-middle bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 寄信图标(可选,如果需要的话) */}
|
||||
{/* <div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
|
||||
<img
|
||||
src={getStaticImage("components/letter/寄信图标")}
|
||||
alt="寄信"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 背面:只读态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 信件内容区域(只读) */}
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给鲁迅的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToMaozedongProps {
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '实事求是', label: '实事求是' },
|
||||
{ value: '群众路线', label: '群众路线' },
|
||||
{ value: '独立自主', label: '独立自主' },
|
||||
],
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的毛泽东主席:
|
||||
|
||||
您好!
|
||||
|
||||
我是____专业的学生。今天,我怀着崇敬又激动的心情,参观了位于茂名北路120弄甲秀里的上海毛泽东旧居陈列馆,学习了毛泽东思想活的灵魂——____、____、____。我深刻体会到,这十二个字不仅是革命年代的智慧结晶,更是新时代指引我们前行的思想灯塔。
|
||||
|
||||
作为中职生,我深知“____”要求我们在专业学习中脚踏实地,掌握扎实技能;“____”提醒我们,未来无论从事什么职业,都要以服务社会、贡献他人为己任;而“独立自主”则激励我们,在技术革新中____,像C919大飞机团队那样,用____本领(结合所学专业)为推进中国式现代化贡献自己的力量!
|
||||
|
||||
站在新时代的起点,我一定以____为志向,勤学苦练,精益求精,让____精神在手中传承,让青春在建设祖国的实践中绽放光彩!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '计算机',
|
||||
},
|
||||
1: {
|
||||
...commonSelectField,
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是毛泽东思想活的灵魂之一“实事求是”。',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
2: {
|
||||
...commonSelectField,
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“群众路线”。',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
3: {
|
||||
...commonSelectField,
|
||||
correctValue: '独立自主',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是毛泽东思想活的灵魂之一“独立自主”。',
|
||||
},
|
||||
readOnlyFallback: '独立自主',
|
||||
},
|
||||
4: {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '实事求是', label: '实事求是' },
|
||||
{ value: '群众路线', label: '群众路线' },
|
||||
],
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是在专业学习中脚踏实地、掌握扎实技能。',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
5: {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '实事求是', label: '实事求是' },
|
||||
{ value: '群众路线', label: '群众路线' },
|
||||
],
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是服务社会、贡献他人的“群众路线”精神。',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
6: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '勇于突破',
|
||||
},
|
||||
7: {
|
||||
type: 'input',
|
||||
maxLength: 14,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '专业技能',
|
||||
},
|
||||
8: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '技能报国',
|
||||
},
|
||||
9: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToMaozedong = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/1东方红.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-毛泽东',
|
||||
}: LetterToMaozedongProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给毛泽东的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToMaozedong
|
||||
@@ -0,0 +1,109 @@
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToNieerProps {
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '音乐救国', label: '音乐救国' },
|
||||
{ value: '为人民而歌', label: '为人民而歌' },
|
||||
{ value: '探索创新', label: '探索创新' },
|
||||
],
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的聂耳先生:
|
||||
|
||||
您好!
|
||||
|
||||
今天,我循着激昂的旋律走进您的旧居与国歌展示馆,看着您手写的《义勇军进行曲》曲谱、创作的《开矿歌》《卖报歌》等作品手稿,仿佛能看到您在亭子间熬夜打磨旋律的身影。在这场跨越时空的对话中,我终于明白:您坚守的“____”,不是空洞的口号,而是以音符为武器的担当;您秉持的“____”,不是简单的创作理念,而是扎根生活、传递真情的初心;您践行的“____”,不是盲目的尝试,而是紧跟时代、突破自我的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您“勤学苦练”的毅力,在专业技能上精益求精,就像您每天坚持练习小提琴,打磨每一个音符;我会以您“心系民众”的热忱,参与____活动,用自己的所长温暖他人;我更会记住您“以旋律传力量”的智慧,在时代浪潮中坚守初心,努力用____为民族复兴、国家强盛添砖加瓦。
|
||||
|
||||
请您放心,您谱写的“起来!不愿做奴隶的人们!”的呐喊,我们从未忘记;您用青春铸就的爱国精神,我们会笃实践行!正如您所期盼的,这激昂的国歌旋律,永远是我们前行的号角!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
...commonSelectField,
|
||||
correctValue: '音乐救国',
|
||||
hints: {
|
||||
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '音乐救国',
|
||||
},
|
||||
1: {
|
||||
...commonSelectField,
|
||||
correctValue: '为人民而歌',
|
||||
hints: {
|
||||
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '为人民而歌',
|
||||
},
|
||||
2: {
|
||||
...commonSelectField,
|
||||
correctValue: '探索创新',
|
||||
hints: {
|
||||
error: '请结合专家的解读和聂耳的创作经历再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '探索创新',
|
||||
},
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '音乐',
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '志愿服务',
|
||||
},
|
||||
5: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '专业所长',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToNieer = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/5我爱你中国.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-聂耳',
|
||||
}: LetterToNieerProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给聂耳的信"
|
||||
letterContent={letterContent}
|
||||
contentClassName="!text-[25px]"
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToNieer
|
||||
@@ -0,0 +1,100 @@
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToRongdeshengProps {
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '国家', label: '国家' },
|
||||
{ value: '个人', label: '个人' },
|
||||
],
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的荣德生先生:
|
||||
您好!
|
||||
|
||||
我是____专业的学生。今天,我怀着崇敬的心情参观了您的旧居,了解了您从钱庄学徒到“面粉大王”“纺织巨子”的传奇一生,读懂了您“____”的初心与担当。您在民族危亡时的气节、在艰难时局中的坚守、在事业成功后的奉献,都让我深受震撼。
|
||||
|
||||
作为新时代青年学生,我会在专业学习中____,像您当年那样钻研实业、精益求精,练就过硬的专业本领;树立正确的财富观和事业观;未来无论从事什么工作,都要把助力____发展放在首位,将个人理想与民族复兴紧密结合;面对困难与挑战时,我会以您为榜样,____,敢于攻坚克难,用自己的专业能力为中国式现代化建设贡献青春力量!
|
||||
|
||||
站在新时代的起点,我一定铭记您的精神,勤学苦练、笃行实干,让实业报国的种子在心中扎根,让奋斗成为青春最亮丽的底色,努力成为一名有理想、有本领、有担当的新时代青年!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '计算机',
|
||||
},
|
||||
1: {
|
||||
type: 'input',
|
||||
maxLength: 16,
|
||||
widthClassName: 'w-[120px]',
|
||||
correctValue: '实业救国',
|
||||
readOnlyFallback: '实业救国',
|
||||
hints: {
|
||||
error: '“实业救国”是荣德生先生一生坚守的初心,也是他毕生践行的信念。',
|
||||
},
|
||||
},
|
||||
2: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '努力',
|
||||
},
|
||||
3: {
|
||||
...commonSelectField,
|
||||
correctValue: '国家',
|
||||
readOnlyFallback: '国家',
|
||||
hints: {
|
||||
error: '青年学生应该将个人理想融入国家发展之中。请你再想想吧!',
|
||||
},
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToRongdesheng = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/7走向复兴.mp3',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-荣德生',
|
||||
}: LetterToRongdeshengProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给荣德生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToRongdesheng
|
||||
@@ -1,435 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { getStaticImage } from '@/lib/utils'
|
||||
import { Image, Select } from '@/components'
|
||||
import { ErrorAlert } from '@/components/layout'
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToSunzhongshanProps {
|
||||
/** 是否可见 */
|
||||
visible?: boolean
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void
|
||||
/** 提交回调 */
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const commonSelectField: LetterFieldConfig = {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '天下为公', label: '天下为公' },
|
||||
{ value: '敢为天下先', label: '敢为天下先' },
|
||||
{ value: '振兴中华', label: '振兴中华' },
|
||||
],
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的孙中山先生:
|
||||
您好!
|
||||
|
||||
今天,我怀着崇敬的心情走进香山路 7 号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在李教授的解读中,我终于明白:您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
|
||||
|
||||
请您放心,“革命尚未成功,同志仍须努力”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
...commonSelectField,
|
||||
correctValue: '振兴中华',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是孙中山先生一生为之奋斗的理想。',
|
||||
},
|
||||
readOnlyFallback: '振兴中华',
|
||||
},
|
||||
1: {
|
||||
...commonSelectField,
|
||||
correctValue: '天下为公',
|
||||
hints: {
|
||||
error: '再想想哦,这里强调的是孙中山先生心系国家与人民的博大胸怀。',
|
||||
},
|
||||
readOnlyFallback: '天下为公',
|
||||
},
|
||||
2: {
|
||||
...commonSelectField,
|
||||
correctValue: '敢为天下先',
|
||||
hints: {
|
||||
error: '再想想哦,这里对应的是孙中山先生敢于探索新道路的胆识。',
|
||||
},
|
||||
readOnlyFallback: '敢为天下先',
|
||||
},
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '政治',
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '实践担当',
|
||||
},
|
||||
5: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '时代发展',
|
||||
},
|
||||
6: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '所学本领',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* LetterToSunzhongshan 组件 - 写给孙中山爷爷的信
|
||||
* 包含飞入和放大动画
|
||||
*/
|
||||
const LetterToSunzhongshan = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/2理想.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-孙中山',
|
||||
}: LetterToSunzhongshanProps) => {
|
||||
// 下划线可填写内容
|
||||
const [blankValues, setBlankValues] = useState<string[]>([])
|
||||
// 每个空的校验状态:true 表示该空当前不符合要求(为空或超过 10 个字)
|
||||
const [blankErrors, setBlankErrors] = useState<boolean[]>([])
|
||||
|
||||
// 是否已提交:提交后隐藏编辑信件,展示只读信件
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
|
||||
// ErrorAlert 相关状态
|
||||
const [showErrorAlert, setShowErrorAlert] = useState(false)
|
||||
const [errorAlertText, setErrorAlertText] = useState('')
|
||||
|
||||
// 前三个空的正确答案
|
||||
const BLANK_0_ANSWER = '振兴中华'
|
||||
const BLANK_1_ANSWER = '天下为公'
|
||||
const BLANK_2_ANSWER = '与时俱进'
|
||||
// 最后一个空的正确答案
|
||||
const LAST_BLANK_ANSWER = '仍需努力'
|
||||
|
||||
// 前三个空的选项(所有空都用这三个选项)
|
||||
const SELECT_OPTIONS = [
|
||||
{ value: '振兴中华', label: '振兴中华' },
|
||||
{ value: '天下为公', label: '天下为公' },
|
||||
{ value: '与时俱进', label: '与时俱进' },
|
||||
]
|
||||
|
||||
// 校验输入:前三个空必须选择正确答案,最后一个空必须是"仍需努力",其他空不能为空且不超过 10 个字符
|
||||
const validateBlanks = () => {
|
||||
// 计算占位总数,防止有些没被输入过而是 undefined
|
||||
const placeholderCount = (letterContent.match(/____/g) || []).length
|
||||
|
||||
const nextErrors: boolean[] = Array(placeholderCount).fill(false)
|
||||
const nextValues: string[] = [...blankValues]
|
||||
let hasError = false
|
||||
|
||||
for (let i = 0; i < placeholderCount; i++) {
|
||||
const raw = blankValues[i] || ''
|
||||
const v = raw.trim()
|
||||
|
||||
if (i < 3) {
|
||||
// 前三个空:必须选择正确答案(已在 onChange 中验证,这里只检查是否为空)
|
||||
if (!v) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else if (i === placeholderCount - 1) {
|
||||
// 最后一个空:必须是"仍需努力"
|
||||
if (!v || v !== LAST_BLANK_ANSWER) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
} else {
|
||||
// 其他空:不能为空,不超过 10 个字
|
||||
if (!v || v.length > 10) {
|
||||
nextValues[i] = ''
|
||||
nextErrors[i] = true
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBlankValues(nextValues)
|
||||
setBlankErrors(nextErrors)
|
||||
|
||||
return !hasError
|
||||
}
|
||||
|
||||
// 弹窗关闭后重置内容,方便下次重新填写
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setShowErrorAlert(false)
|
||||
setErrorAlertText('')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 信件内容模板(根据设计稿)
|
||||
const letterContent = `敬爱的孙中山先生:
|
||||
|
||||
您好!
|
||||
|
||||
今天,我怀着崇敬的心情走进香山路7号,看着您亲手设计的房屋、勾画的《中国铁路全图》、撰写的《实业计划》手稿,仿佛能看到您为“振兴中华”奔走的身影。在这场跨越时空的对话中,我终于明白,您喊出的“____”,不是遥远的历史,而是我们这代人正在接续的接力;您追求的“____”,不是抽象的理念,而是从服务身边人、做好专业事开始的担当;您秉持的“____”,不是过去的选择,而是面对未来时必须有的勇气。
|
||||
|
||||
我是____专业的学生,今后,我会以您“知难行易”的信念,在专业学习中攻克难题,就像您当年为了革命理想屡败屡战;我会以您“心系民众”的情怀,参与____,帮身边人解决实际困难;我更会记住您“适乎世界之潮流”的智慧,紧跟时代步伐,努力用____为民族复兴添砖加瓦。
|
||||
|
||||
请您放心,“革命尚未成功,同志____”的嘱托,我们从未忘记;“振兴中华”的初心,我们会用一生践行!`
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-[100]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 信件容器 */}
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[101] flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-[1200px] h-full pointer-events-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ perspective: '1200px' }}
|
||||
>
|
||||
{/* 编辑态 / 只读态:Y 轴翻转切换 */}
|
||||
<motion.div
|
||||
className="relative w-full h-full"
|
||||
initial={false}
|
||||
animate={{ rotateY: submitted ? 180 : 0 }}
|
||||
transition={{ duration: 0.7, ease: 'easeInOut' }}
|
||||
style={{ transformStyle: 'preserve-3d' }}
|
||||
>
|
||||
{/* 前面:编辑态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backfaceVisibility: 'hidden',
|
||||
// 编辑态展示时才响应事件,避免与背面点击冲突
|
||||
pointerEvents: submitted ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 编辑态:信纸背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信底')}
|
||||
alt="信纸"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* 标题 - 垂直显示在左侧 */}
|
||||
<div className="absolute left-[160px] top-20 bottom-0 [text-shadow:_0px_0px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921,_6px_6px_4px_#b37921]">
|
||||
<div
|
||||
className="text-white text-6xl font-bold tracking-[20px]"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'upright',
|
||||
}}
|
||||
>
|
||||
写给孙中山先生的信
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 信件内容区域(可编辑) */}
|
||||
<div className="absolute w-[700px] left-[320px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
// 检测是否已经找到"您好"行
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
// "您好"行之后的非空行需要首行缩进
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const isSelectFill = currentIndex < 3 // 前三个空使用 Select
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{isSelectFill ? (
|
||||
// 前三个空:下拉选择
|
||||
<Select
|
||||
className="inline"
|
||||
options={SELECT_OPTIONS}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(selectedValue) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = selectedValue
|
||||
setBlankValues(next)
|
||||
|
||||
// 清除错误状态
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
|
||||
// 立即验证选择是否正确
|
||||
// let correctAnswer = "";
|
||||
// if (currentIndex === 0) {
|
||||
// correctAnswer = BLANK_0_ANSWER;
|
||||
// } else if (currentIndex === 1) {
|
||||
// correctAnswer = BLANK_1_ANSWER;
|
||||
// } else if (currentIndex === 2) {
|
||||
// correctAnswer = BLANK_2_ANSWER;
|
||||
// }
|
||||
|
||||
// if (
|
||||
// correctAnswer &&
|
||||
// selectedValue !== correctAnswer
|
||||
// ) {
|
||||
// // 选择错误,显示错误提示
|
||||
// setErrorAlertText(
|
||||
// "请选择正确答案"
|
||||
// );
|
||||
// setShowErrorAlert(true);
|
||||
// // 清空选择
|
||||
// next[currentIndex] = "";
|
||||
// setBlankValues(next);
|
||||
// const nextErrs = [...blankErrors];
|
||||
// nextErrs[currentIndex] = true;
|
||||
// setBlankErrors(nextErrs);
|
||||
// }
|
||||
}}
|
||||
placeholder="请选择"
|
||||
error={blankErrors[currentIndex]}
|
||||
zIndex={10000}
|
||||
fitContent={true}
|
||||
/>
|
||||
) : (
|
||||
// 其他空:文本输入
|
||||
<input
|
||||
name={`blank-${currentIndex}`}
|
||||
className={`inline-block w-[110px] h-[36px] mx-2 bg-transparent border-b-2 border-dashed [font-size:26px_!important] text-center outline-none ${
|
||||
blankErrors[currentIndex]
|
||||
? 'border-red-500'
|
||||
: 'border-[#4e402e]'
|
||||
}`}
|
||||
value={blankValues[currentIndex] ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...blankValues]
|
||||
next[currentIndex] = e.target.value
|
||||
setBlankValues(next)
|
||||
|
||||
// 用户再次输入时,清除该空的错误高亮
|
||||
if (blankErrors[currentIndex]) {
|
||||
const nextErrs = [...blankErrors]
|
||||
nextErrs[currentIndex] = false
|
||||
setBlankErrors(nextErrs)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 笔的图片 */}
|
||||
<div className="absolute bottom-[150px] -right-30">
|
||||
<img
|
||||
src={getStaticImage('components/letter/笔')}
|
||||
alt="笔"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮:切换到只读信件态 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/可提交状态')}
|
||||
alt="可提交状态"
|
||||
onClick={() => {
|
||||
if (!validateBlanks()) return
|
||||
setSubmitted(true)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 寄信图标(可选,如果需要的话) */}
|
||||
{/* <div className="absolute top-[50px] right-[50px] w-[100px] h-[100px] opacity-80">
|
||||
<img
|
||||
src={getStaticImage("components/letter/寄信图标")}
|
||||
alt="寄信"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 背面:只读态 */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
transform: 'rotateY(180deg)',
|
||||
backfaceVisibility: 'hidden',
|
||||
// 只读态展示时才响应事件
|
||||
pointerEvents: submitted ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* 只读态:新背景 */}
|
||||
<img
|
||||
src={getStaticImage('components/letter/信')}
|
||||
alt="信件"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
<Image
|
||||
src={getStaticImage('components/close')}
|
||||
alt="信纸"
|
||||
className="absolute right-38 top-0"
|
||||
button
|
||||
onClick={() => {
|
||||
setSubmitted(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 信件内容区域(只读) */}
|
||||
<div className="absolute w-[700px] left-[260px] right-[180px] top-[80px] bottom-[180px] z-99">
|
||||
<div className="text-[#4e402e] text-[26px] leading-relaxed">
|
||||
{(() => {
|
||||
let placeholderIndex = 0
|
||||
let foundNiHao = false
|
||||
return letterContent.split('\n').map((line, lineIndex) => {
|
||||
if (line.includes('您好')) {
|
||||
foundNiHao = true
|
||||
}
|
||||
|
||||
const needsIndent =
|
||||
foundNiHao && line.trim() !== '' && !line.includes('您好')
|
||||
|
||||
const segments = line.split('____')
|
||||
return (
|
||||
<div
|
||||
key={lineIndex}
|
||||
className="mb-2"
|
||||
style={needsIndent ? { textIndent: '2em' } : {}}
|
||||
>
|
||||
{segments.map((part, partIndex) => {
|
||||
const isLast = partIndex === segments.length - 1
|
||||
if (isLast) {
|
||||
return <span key={`part-${lineIndex}-${partIndex}`}>{part}</span>
|
||||
}
|
||||
|
||||
const currentIndex = placeholderIndex++
|
||||
const value = blankValues[currentIndex] ?? ''
|
||||
|
||||
return (
|
||||
<span key={`part-${lineIndex}-${partIndex}`}>
|
||||
{part}
|
||||
{value || ' '}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 寄出按钮:关闭窗口 */}
|
||||
<div className="absolute bottom-[80px] left-1/2 transform -translate-x-1/2 z-99">
|
||||
<Image
|
||||
src={getStaticImage('components/letter/寄出')}
|
||||
alt="寄出"
|
||||
onClick={() => {
|
||||
onSubmit?.(blankValues)
|
||||
}}
|
||||
button
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 错误提示弹窗 */}
|
||||
<ErrorAlert
|
||||
visible={showErrorAlert}
|
||||
text={errorAlertText}
|
||||
onClose={() => setShowErrorAlert(false)}
|
||||
onMaskClick={() => setShowErrorAlert(false)}
|
||||
zIndex={10002}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给孙中山的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import LetterTemplate, { type LetterFieldConfig } from './LetterTemplate'
|
||||
|
||||
export interface LetterToZhanglepingProps {
|
||||
visible?: boolean
|
||||
onClose?: () => void
|
||||
onSubmit?: (values: string[]) => void
|
||||
onAnimationEnd?: () => void
|
||||
audioSrc?: string
|
||||
showVideo?: boolean
|
||||
videoSrc?: string
|
||||
replyImageSrc?: string
|
||||
}
|
||||
|
||||
const letterContent = `敬爱的张乐平先生:
|
||||
您好!
|
||||
|
||||
今天,我循着您的笔墨足迹,走进了这座充满着您创作的温度的故居。看着三毛从流浪儿到戴红领巾的成长画卷,了解到您“艺术源于生活”的创作坚持,我终于明白:您坚守的“____”,不是简单的创作技巧,而是扎根现实、传递真情的态度;您追求的“____”,不是刻意的形式简化,而是以少胜多、直击人心的智慧;您传递的“____”,不是空洞的道德说教,而是藏在细节中的悲悯与坚守。
|
||||
|
||||
我是____专业的学生,今后,我会践行您“笔耕不辍”的坚持,在专业学习中打磨____技能,就像您六十多年如一日坚守漫画创作;我会学习您“贴近生活”的真诚,用____帮助身边有需要的人,让美好在细微处传递;我更会传承您“笔墨随时代”的智慧,紧跟潮流,努力用____传递正能量。请您放心,您笔下的善良与勇敢和您坚守的真诚与热爱,会在我们新时代青年的传承下焕发新的光彩!`
|
||||
|
||||
const fields: Record<number, LetterFieldConfig> = {
|
||||
0: {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '源于生活', label: '源于生活' },
|
||||
{ value: '化繁为简', label: '化繁为简' },
|
||||
{ value: '向善向美', label: '向善向美' },
|
||||
],
|
||||
correctValue: '源于生活',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '源于生活',
|
||||
},
|
||||
1: {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '源于生活', label: '源于生活' },
|
||||
{ value: '化繁为简', label: '化繁为简' },
|
||||
{ value: '向善向美', label: '向善向美' },
|
||||
],
|
||||
correctValue: '化繁为简',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '化繁为简',
|
||||
},
|
||||
2: {
|
||||
type: 'select',
|
||||
placeholder: '请选择',
|
||||
fitContent: false,
|
||||
className: '!w-[120px] !min-w-[120px] shrink-0 px-0',
|
||||
options: [
|
||||
{ value: '源于生活', label: '源于生活' },
|
||||
{ value: '化繁为简', label: '化繁为简' },
|
||||
{ value: '向善向美', label: '向善向美' },
|
||||
],
|
||||
correctValue: '向善向美',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '向善向美',
|
||||
},
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '美术',
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '创作',
|
||||
},
|
||||
5: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '行动',
|
||||
},
|
||||
6: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '作品',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToZhangleping = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onAnimationEnd,
|
||||
audioSrc = 'audio/letter/6灯火里的中国.m4a',
|
||||
showVideo = true,
|
||||
videoSrc = 'video/letter/neihua_jixin.mp4',
|
||||
replyImageSrc = 'components/letter/点击信详情-张乐平',
|
||||
}: LetterToZhanglepingProps) => {
|
||||
return (
|
||||
<LetterTemplate
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="寄给张乐平的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
audioSrc={audioSrc}
|
||||
showVideo={showVideo}
|
||||
videoSrc={videoSrc}
|
||||
replyImageSrc={replyImageSrc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LetterToZhangleping
|
||||
@@ -1,6 +1,9 @@
|
||||
export { default as LetterToChenyun } from './LetterToChenyun'
|
||||
export type { LetterToChenyunProps } from './LetterToChenyun'
|
||||
|
||||
export { default as LetterToMaozedong } from './LetterToMaozedong'
|
||||
export type { LetterToMaozedongProps } from './LetterToMaozedong'
|
||||
|
||||
export { default as LetterToSunzhongshan } from './LetterToSunzhongshan'
|
||||
export type { LetterToSunzhongshanProps } from './LetterToSunzhongshan'
|
||||
|
||||
@@ -9,3 +12,12 @@ export type { LetterToHuangyanpeiProps } from './LetterToHuangyanpei'
|
||||
|
||||
export { default as LetterToLuxun } from './LetterToLuxun'
|
||||
export type { LetterToLuxunProps } from './LetterToLuxun'
|
||||
|
||||
export { default as LetterToZhangleping } from './LetterToZhangleping'
|
||||
export type { LetterToZhanglepingProps } from './LetterToZhangleping'
|
||||
|
||||
export { default as LetterToNieer } from './LetterToNieer'
|
||||
export type { LetterToNieerProps } from './LetterToNieer'
|
||||
|
||||
export { default as LetterToRongdesheng } from './LetterToRongdesheng'
|
||||
export type { LetterToRongdeshengProps } from './LetterToRongdesheng'
|
||||
@@ -114,10 +114,10 @@ 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-transparent">
|
||||
{/* 文本内容层 */}
|
||||
<div
|
||||
className="relative z-10 flex items-center max-w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
|
||||
className="relative z-10 flex items-center w-full transition-all py-50 px-24 h-[825px] bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${getStaticImage('components/overlay/bg')})`,
|
||||
}}
|
||||
|
||||
@@ -15,6 +15,7 @@ const getResidenceDisplayName = (residence: string): string => {
|
||||
huangyanpei: '黄炎培',
|
||||
luxun: '鲁迅',
|
||||
sunzhongshan: '孙中山',
|
||||
maozedong: '毛泽东',
|
||||
}
|
||||
|
||||
return nameMap[residence] || residence
|
||||
@@ -262,7 +263,7 @@ const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
<div className="flex items-center gap-4 px-6 pt-30">
|
||||
<div className="w-200 h-200 perspective-[1000px]">
|
||||
{/* SVG拼图容器 - 使用3D变换和缩放 */}
|
||||
<div className="relative h-full transform-3d rotate-x-20 -rotate-y-0 -rotate-z-0 scale-85">
|
||||
<div className="relative h-full transform-3d rotate-x-20 rotate-y-0 rotate-z-0 scale-85">
|
||||
<svg
|
||||
viewBox={getPuzzleViewBox(residence)}
|
||||
className="w-full h-full"
|
||||
|
||||
@@ -453,7 +453,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
)}
|
||||
>
|
||||
{renderOption
|
||||
? renderOption(option, isUsed)
|
||||
? renderOption(option, isUsed, isDragging)
|
||||
: defaultRenderOption(option, isUsed)}
|
||||
</div>
|
||||
)
|
||||
@@ -515,7 +515,7 @@ const DragFill = forwardRef<HTMLDivElement, DragFillProps>(
|
||||
dragPosition &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed pointer-events-none z-[99999]"
|
||||
className="fixed pointer-events-none z-99999"
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
|
||||
@@ -61,7 +61,7 @@ export interface DragFillProps {
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmitButton?: boolean
|
||||
/** 自定义渲染选项 */
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean) => ReactNode
|
||||
renderOption?: (option: DragFillOption, isUsed: boolean, isDragging: boolean) => ReactNode
|
||||
/** 自定义渲染空白区域 */
|
||||
renderBlank?: (blank: DragFillBlank, filledValue: string | null, isHovered: boolean) => ReactNode
|
||||
/** 自定义渲染拖拽中的元素 */
|
||||
|
||||
@@ -34,6 +34,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
fitContent = false,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
fontSize = 26,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -41,7 +42,6 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const measureRef = useRef<HTMLSpanElement>(null)
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{
|
||||
top: number
|
||||
left: number
|
||||
@@ -49,30 +49,23 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
} | null>(null)
|
||||
const [contentWidth, setContentWidth] = useState<number | null>(null)
|
||||
|
||||
// 获取当前选中的选项
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
// 计算内容宽度(用于 fitContent)
|
||||
const calculateContentWidth = useCallback(() => {
|
||||
if (!fitContent || !triggerRef.current) return
|
||||
|
||||
// 获取触发器的计算样式,用于匹配字体
|
||||
const triggerStyles = window.getComputedStyle(triggerRef.current)
|
||||
|
||||
// 创建一个临时元素来测量所有选项的宽度
|
||||
const tempElement = document.createElement('span')
|
||||
tempElement.style.visibility = 'hidden'
|
||||
tempElement.style.position = 'absolute'
|
||||
tempElement.style.whiteSpace = 'nowrap'
|
||||
tempElement.style.fontSize = triggerStyles.fontSize
|
||||
tempElement.style.fontSize = fontSize ? `${fontSize}px` : triggerStyles.fontSize
|
||||
tempElement.style.fontFamily = triggerStyles.fontFamily
|
||||
tempElement.style.fontWeight = triggerStyles.fontWeight
|
||||
tempElement.style.padding = '8px 12px' // 与选项 padding 一致 (py-2 px-3)
|
||||
tempElement.style.padding = '8px 12px'
|
||||
document.body.appendChild(tempElement)
|
||||
|
||||
let calculatedWidth = 0
|
||||
|
||||
// 测量所有选项和占位符的宽度
|
||||
const textsToMeasure = [placeholder, ...options.map((opt) => opt.label)]
|
||||
|
||||
textsToMeasure.forEach((text) => {
|
||||
@@ -85,7 +78,6 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
|
||||
document.body.removeChild(tempElement)
|
||||
|
||||
// 应用最小和最大宽度限制
|
||||
if (minWidth !== undefined && calculatedWidth < minWidth) {
|
||||
calculatedWidth = minWidth
|
||||
}
|
||||
@@ -94,61 +86,48 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
}
|
||||
|
||||
setContentWidth(calculatedWidth)
|
||||
}, [fitContent, options, placeholder, minWidth, maxWidth])
|
||||
}, [fitContent, maxWidth, minWidth, options, placeholder])
|
||||
|
||||
// 计算下拉菜单位置
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (!triggerRef.current) return
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
let width = rect.width
|
||||
|
||||
// 如果启用 fitContent,使用计算出的内容宽度
|
||||
if (fitContent && contentWidth !== null) {
|
||||
// width = contentWidth;
|
||||
width = 0
|
||||
}
|
||||
const width = fitContent && contentWidth !== null ? contentWidth : rect.width
|
||||
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.left + window.scrollX,
|
||||
width: width,
|
||||
width,
|
||||
})
|
||||
}, [fitContent, contentWidth])
|
||||
}, [contentWidth, fitContent])
|
||||
|
||||
// 打开下拉菜单
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return
|
||||
// 如果启用 fitContent,先计算内容宽度
|
||||
|
||||
if (fitContent) {
|
||||
calculateContentWidth()
|
||||
}
|
||||
// 使用 setTimeout 确保内容宽度计算完成后再更新位置
|
||||
|
||||
setTimeout(() => {
|
||||
updateDropdownPosition()
|
||||
setIsOpen(true)
|
||||
setFocusedIndex(value ? options.findIndex((opt) => opt.value === value) : -1)
|
||||
}, 0)
|
||||
}, [disabled, fitContent, calculateContentWidth, updateDropdownPosition, value, options])
|
||||
}, [calculateContentWidth, disabled, fitContent, options, updateDropdownPosition, value])
|
||||
|
||||
// 关闭下拉菜单
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setFocusedIndex(-1)
|
||||
}, [])
|
||||
|
||||
// 选择选项
|
||||
const handleSelect = useCallback(
|
||||
(optionValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(optionValue)
|
||||
}
|
||||
onChange?.(optionValue)
|
||||
handleClose()
|
||||
},
|
||||
[onChange, handleClose]
|
||||
[handleClose, onChange]
|
||||
)
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
@@ -180,9 +159,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [isOpen, handleClose, updateDropdownPosition])
|
||||
}, [handleClose, isOpen, updateDropdownPosition])
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
@@ -231,9 +209,8 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, focusedIndex, options, handleSelect, handleClose])
|
||||
}, [focusedIndex, handleClose, handleSelect, isOpen, options])
|
||||
|
||||
// 滚动到选中项
|
||||
useEffect(() => {
|
||||
if (isOpen && focusedIndex >= 0 && dropdownRef.current) {
|
||||
const optionElement = dropdownRef.current.querySelector(
|
||||
@@ -246,24 +223,12 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isOpen, focusedIndex])
|
||||
}, [focusedIndex, isOpen])
|
||||
|
||||
const displayText = selectedOption ? selectedOption.label : placeholder
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 隐藏的测量元素(用于 fitContent 模式) */}
|
||||
{fitContent && (
|
||||
<span
|
||||
ref={measureRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 触发器 */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (typeof ref === 'function') {
|
||||
@@ -274,11 +239,13 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
triggerRef.current = node
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex justify-start w-full h-[36px] mx-2 bg-transparent border-b-2 border-dashed text-[26px] outline-none cursor-pointer',
|
||||
'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]',
|
||||
error ? 'border-red-500' : 'border-[#4e402e]',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)}
|
||||
style={fontSize ? { fontSize: `${fontSize}px` } : undefined}
|
||||
onClick={handleOpen}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
@@ -286,29 +253,26 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
>
|
||||
{/* <span className={cn("truncate px-2", className)}> */}
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
{/* </span> */}
|
||||
{/* <span
|
||||
className={cn("ml-1 transition-transform", isOpen && "rotate-180")}
|
||||
<span
|
||||
className="block whitespace-nowrap text-center leading-none"
|
||||
style={fontSize ? { fontSize: `${fontSize}px` } : undefined}
|
||||
>
|
||||
▼
|
||||
</span> */}
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 下拉菜单 - 使用 Portal 渲染到 body */}
|
||||
{isOpen &&
|
||||
dropdownPosition &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-white border border-[#4e402e] rounded shadow-lg max-h-[200px] overflow-auto z-[1000]"
|
||||
className="fixed max-h-[200px] overflow-auto rounded border border-[#4e402e] bg-white shadow-lg"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width > 0 ? dropdownPosition.width : 'auto'}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
minWidth: '80px',
|
||||
zIndex: zIndex,
|
||||
zIndex,
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
@@ -322,10 +286,10 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(
|
||||
key={option.value}
|
||||
data-option-index={index}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer text-[1vw] text-[#4e402e] transition-colors text-center whitespace-nowrap',
|
||||
'cursor-pointer whitespace-nowrap px-3 py-2 text-center text-[1vw] text-[#4e402e] transition-colors',
|
||||
isSelected && 'bg-[#4e402e]/10 font-medium',
|
||||
isFocused && !isSelected && 'bg-gray-100',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
isDisabled && 'pointer-events-none cursor-not-allowed opacity-50'
|
||||
)}
|
||||
onClick={() => !isDisabled && handleSelect(option.value)}
|
||||
role="option"
|
||||
|
||||
@@ -34,4 +34,6 @@ export interface SelectProps {
|
||||
minWidth?: number
|
||||
/** 下拉菜单最大宽度(当 fitContent 为 true 时生效) */
|
||||
maxWidth?: number
|
||||
/** 字体大小(单位:px) */
|
||||
fontSize?: number
|
||||
}
|
||||
+162
-280
@@ -70,10 +70,13 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
const [triggeredOnceIds, setTriggeredOnceIds] = useState<Set<string>>(new Set())
|
||||
// 当前激活的锚点 ID
|
||||
const [activeAnchorId, setActiveAnchorId] = useState<string | null>(null)
|
||||
const activeAnchorIdRef = useRef<string | null>(null)
|
||||
// 已隐藏的锚点 ID(afterResume: 'hide')
|
||||
const [hiddenAnchorIds, setHiddenAnchorIds] = useState<Set<string>>(new Set())
|
||||
// 已移除的锚点 ID(afterResume: 'remove')
|
||||
const [removedAnchorIds, setRemovedAnchorIds] = useState<Set<string>>(new Set())
|
||||
// 已完成的锚点 ID(用户已交互并恢复播放,用于阻止进度条跳过)
|
||||
const [completedAnchorIds, setCompletedAnchorIds] = useState<Set<string>>(new Set())
|
||||
const isHandlingAnchor = useRef(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -91,7 +94,61 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
},
|
||||
}))
|
||||
|
||||
// 初始化视频信息
|
||||
// ─── Helper 函数 ───
|
||||
|
||||
/** 强制暂停视频(含 rAF 兜底,Safari 兼容) */
|
||||
const forcePause = useCallback((video: HTMLVideoElement) => {
|
||||
video.pause()
|
||||
requestAnimationFrame(() => {
|
||||
if (!video.paused) {
|
||||
video.pause()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 锚点是否可被触发(用于 timeUpdate / seek 触发) */
|
||||
const isAnchorTriggerable = useCallback(
|
||||
(anchor: VideoAnchor): boolean => {
|
||||
if (anchor.enabled === false) return false
|
||||
if (removedAnchorIds.has(anchor.id)) return false
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) return false
|
||||
if (hiddenAnchorIds.has(anchor.id)) return false
|
||||
if (completedAnchorIds.has(anchor.id)) return false
|
||||
if (activeAnchorId === anchor.id) return false
|
||||
return true
|
||||
},
|
||||
[removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, completedAnchorIds, activeAnchorId]
|
||||
)
|
||||
|
||||
/** 锚点是否未完成(用于 seek 拦截) */
|
||||
const isAnchorUncompleted = useCallback(
|
||||
(anchor: VideoAnchor): boolean => {
|
||||
if (anchor.enabled === false) return false
|
||||
if (removedAnchorIds.has(anchor.id)) return false
|
||||
if (completedAnchorIds.has(anchor.id)) return false
|
||||
return true
|
||||
},
|
||||
[removedAnchorIds, completedAnchorIds]
|
||||
)
|
||||
|
||||
/** 触发锚点:暂停 + 设置状态 + 回调 */
|
||||
const triggerAnchor = useCallback(
|
||||
(anchor: VideoAnchor, video?: HTMLVideoElement | null) => {
|
||||
if (video) {
|
||||
forcePause(video)
|
||||
}
|
||||
isHandlingAnchor.current = true
|
||||
if (anchor.once) {
|
||||
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
|
||||
}
|
||||
setActiveAnchorId(anchor.id)
|
||||
anchor.onTrigger?.()
|
||||
},
|
||||
[forcePause]
|
||||
)
|
||||
|
||||
// ─── 初始化与生命周期 ───
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
@@ -107,7 +164,6 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
setIsVideoReady(true)
|
||||
}
|
||||
|
||||
// 如果视频已经有尺寸,立即设置为ready
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
setIsVideoReady(true)
|
||||
}
|
||||
@@ -120,6 +176,18 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保持 activeAnchorIdRef 与 state 同步(解决闭包过期问题)
|
||||
useEffect(() => {
|
||||
activeAnchorIdRef.current = activeAnchorId
|
||||
}, [activeAnchorId])
|
||||
|
||||
// 锚点激活时强制暂停视频(兜底保护)
|
||||
useEffect(() => {
|
||||
if (activeAnchorId && videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
}, [activeAnchorId])
|
||||
|
||||
// 全屏状态监听
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
@@ -166,6 +234,9 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
return () => {
|
||||
const afterResume = anchor.afterResume ?? 'hide'
|
||||
|
||||
// 标记锚点为已完成(防止进度条跳过)
|
||||
setCompletedAnchorIds((prev) => new Set(prev).add(anchor.id))
|
||||
|
||||
if (afterResume === 'hide') {
|
||||
setHiddenAnchorIds((prev) => new Set(prev).add(anchor.id))
|
||||
setActiveAnchorId(null)
|
||||
@@ -180,238 +251,140 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─── 锚点触发与 seek 逻辑 ───
|
||||
|
||||
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement, Event>): void => {
|
||||
if (onTimeUpdate) {
|
||||
onTimeUpdate(e)
|
||||
}
|
||||
onTimeUpdate?.(e)
|
||||
|
||||
const video = e.currentTarget
|
||||
const currentTime = video.currentTime
|
||||
setCurrentTime(currentTime)
|
||||
const time = video.currentTime
|
||||
setCurrentTime(time)
|
||||
|
||||
if (isHandlingAnchor.current) return
|
||||
|
||||
for (const anchor of anchors) {
|
||||
// 检查是否启用
|
||||
if (anchor.enabled === false) continue
|
||||
// 检查是否已移除
|
||||
if (removedAnchorIds.has(anchor.id)) continue
|
||||
// 检查 once 逻辑
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
|
||||
// 检查是否已隐藏(隐藏的锚点不再触发)
|
||||
if (hiddenAnchorIds.has(anchor.id)) continue
|
||||
// keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
|
||||
if (activeAnchorId === anchor.id) continue
|
||||
|
||||
const timeDiff = currentTime - anchor.time
|
||||
|
||||
if (!isAnchorTriggerable(anchor)) continue
|
||||
const timeDiff = time - anchor.time
|
||||
if (timeDiff >= 0 && timeDiff < 0.5) {
|
||||
console.log(`Anchor triggered: ${anchor.id} at ${anchor.time}s`)
|
||||
|
||||
// 暂停视频 - Safari 兼容:确保暂停生效
|
||||
video.pause()
|
||||
// 双重保险:延迟再次确认暂停
|
||||
requestAnimationFrame(() => {
|
||||
if (!video.paused) {
|
||||
video.pause()
|
||||
}
|
||||
})
|
||||
isHandlingAnchor.current = true
|
||||
|
||||
// 标记为已触发(用于 once 逻辑)
|
||||
if (anchor.once) {
|
||||
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
|
||||
}
|
||||
|
||||
// 设置当前激活的锚点
|
||||
setActiveAnchorId(anchor.id)
|
||||
|
||||
// 触发回调(如埋点)
|
||||
anchor.onTrigger?.()
|
||||
|
||||
triggerAnchor(anchor, video)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并触发锚点的辅助函数
|
||||
// 检查并触发锚点(用于 seek 后检测)
|
||||
const checkAndTriggerAnchors = useCallback(
|
||||
(targetTime: number) => {
|
||||
if (!videoRef.current || isHandlingAnchor.current) return
|
||||
|
||||
for (const anchor of anchors) {
|
||||
// 检查是否启用
|
||||
if (anchor.enabled === false) continue
|
||||
// 检查是否已移除
|
||||
if (removedAnchorIds.has(anchor.id)) continue
|
||||
// 检查 once 逻辑
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
|
||||
// 检查是否已隐藏(隐藏的锚点不再触发)
|
||||
if (hiddenAnchorIds.has(anchor.id)) continue
|
||||
// keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
|
||||
if (activeAnchorId === anchor.id) continue
|
||||
|
||||
// 检查是否跳过了锚点时间(允许0.5秒的容差)
|
||||
if (!isAnchorTriggerable(anchor)) continue
|
||||
const timeDiff = targetTime - anchor.time
|
||||
if (timeDiff >= 0 && timeDiff < 0.5) {
|
||||
console.log(
|
||||
`Anchor triggered: ${anchor.id} at ${anchor.time}s (seeked to ${targetTime}s)`
|
||||
)
|
||||
|
||||
// 立即暂停视频(多次确认确保暂停生效)
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
// 同步检查并再次暂停(Safari 兼容)
|
||||
if (!videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
// 双重保险:延迟再次确认暂停
|
||||
requestAnimationFrame(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
})
|
||||
// 三重保险:再延迟一次确认
|
||||
setTimeout(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
isHandlingAnchor.current = true
|
||||
|
||||
// 标记为已触发(用于 once 逻辑)
|
||||
if (anchor.once) {
|
||||
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
|
||||
}
|
||||
|
||||
// 设置当前激活的锚点
|
||||
setActiveAnchorId(anchor.id)
|
||||
|
||||
// 触发回调(如埋点)
|
||||
anchor.onTrigger?.()
|
||||
|
||||
triggerAnchor(anchor, videoRef.current)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, activeAnchorId]
|
||||
[anchors, isAnchorTriggerable, triggerAnchor]
|
||||
)
|
||||
|
||||
// 查找目标时间之前最近的未触发锚点
|
||||
const findNearestAnchorBeforeTime = useCallback(
|
||||
(targetTime: number): VideoAnchor | null => {
|
||||
let nearestAnchor: VideoAnchor | null = null
|
||||
let nearestTime = -1
|
||||
|
||||
// 查找范围内第一个未完成锚点
|
||||
const findFirstUncompletedAnchorInRange = useCallback(
|
||||
(fromTime: number, toTime: number): VideoAnchor | null => {
|
||||
let firstAnchor: VideoAnchor | null = null
|
||||
let firstTime = Infinity
|
||||
for (const anchor of anchors) {
|
||||
// 检查是否启用
|
||||
if (anchor.enabled === false) continue
|
||||
// 检查是否已移除
|
||||
if (removedAnchorIds.has(anchor.id)) continue
|
||||
// 检查 once 逻辑
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
|
||||
// 检查是否已隐藏(隐藏的锚点不再触发)
|
||||
if (hiddenAnchorIds.has(anchor.id)) continue
|
||||
|
||||
// 锚点时间应该在目标时间之前或等于目标时间
|
||||
if (anchor.time <= targetTime && anchor.time > nearestTime) {
|
||||
nearestAnchor = anchor
|
||||
nearestTime = anchor.time
|
||||
if (!isAnchorUncompleted(anchor)) continue
|
||||
if (anchor.time > fromTime && anchor.time <= toTime && anchor.time < firstTime) {
|
||||
firstAnchor = anchor
|
||||
firstTime = anchor.time
|
||||
}
|
||||
}
|
||||
|
||||
return nearestAnchor
|
||||
return firstAnchor
|
||||
},
|
||||
[anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds]
|
||||
[anchors, isAnchorUncompleted]
|
||||
)
|
||||
|
||||
// 带锚点拦截的 seek
|
||||
const seekWithAnchorGuard = useCallback(
|
||||
(targetTime: number) => {
|
||||
if (!videoRef.current) return
|
||||
const firstAnchor = findFirstUncompletedAnchorInRange(currentTime, targetTime)
|
||||
const finalTime = firstAnchor ? firstAnchor.time : targetTime
|
||||
if (firstAnchor) {
|
||||
shouldStopAtAnchorRef.current = { time: finalTime, anchorId: firstAnchor.id }
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
shouldStopAtAnchorRef.current = null
|
||||
}
|
||||
videoRef.current.currentTime = finalTime
|
||||
},
|
||||
[currentTime, findFirstUncompletedAnchorInRange]
|
||||
)
|
||||
|
||||
// 开始跳转时,如果应该停在锚点处,立即暂停
|
||||
const handleSeeking = (): void => {
|
||||
if (!videoRef.current) return
|
||||
// 如果设置了应该停在锚点处,立即暂停
|
||||
if (shouldStopAtAnchorRef.current) {
|
||||
videoRef.current.pause()
|
||||
// 同步检查并再次暂停(Safari 兼容)
|
||||
if (!videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
forcePause(videoRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
// Seek 时重置隐藏状态(非 once 的锚点可以重新触发),并检查是否跳过了锚点
|
||||
const handleSeeked = (): void => {
|
||||
if (!videoRef.current) return
|
||||
const currentTime = videoRef.current.currentTime
|
||||
const time = videoRef.current.currentTime
|
||||
|
||||
// 如果设置了应该停在锚点处,强制暂停并触发锚点
|
||||
if (shouldStopAtAnchorRef.current) {
|
||||
const { time: anchorTime, anchorId } = shouldStopAtAnchorRef.current
|
||||
// 检查当前时间是否在锚点附近(允许0.1秒容差)
|
||||
if (Math.abs(currentTime - anchorTime) < 0.1) {
|
||||
// 强制暂停(多次确认)
|
||||
videoRef.current.pause()
|
||||
if (!videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (videoRef.current && !videoRef.current.paused) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
})
|
||||
|
||||
// 触发锚点逻辑
|
||||
if (Math.abs(time - anchorTime) < 0.1) {
|
||||
forcePause(videoRef.current)
|
||||
if (!isHandlingAnchor.current) {
|
||||
const anchor = anchors.find((a) => a.id === anchorId)
|
||||
if (anchor) {
|
||||
isHandlingAnchor.current = true
|
||||
|
||||
// 标记为已触发(用于 once 逻辑)
|
||||
if (anchor.once) {
|
||||
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id))
|
||||
}
|
||||
|
||||
// 设置当前激活的锚点
|
||||
setActiveAnchorId(anchor.id)
|
||||
|
||||
// 触发回调(如埋点)
|
||||
anchor.onTrigger?.()
|
||||
triggerAnchor(anchor)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除标记
|
||||
shouldStopAtAnchorRef.current = null
|
||||
return
|
||||
}
|
||||
// 如果时间不匹配,清除标记
|
||||
shouldStopAtAnchorRef.current = null
|
||||
}
|
||||
|
||||
// 清除当前时间之后的隐藏状态(允许重新触发)
|
||||
// 清除当前时间之后的隐藏/完成状态(回退后允许重新触发)
|
||||
setHiddenAnchorIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of next) {
|
||||
const anchor = anchors.find((a) => a.id === id)
|
||||
if (anchor && anchor.time > currentTime) {
|
||||
if (anchor && anchor.time > time) {
|
||||
next.delete(id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// 重置锚点处理锁(如果当前没有激活的锚点)
|
||||
if (!activeAnchorId) {
|
||||
setCompletedAnchorIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const id of next) {
|
||||
const anchor = anchors.find((a) => a.id === id)
|
||||
if (anchor && anchor.time > time) {
|
||||
next.delete(id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// 使用 ref 避免闭包过期
|
||||
if (!activeAnchorIdRef.current) {
|
||||
isHandlingAnchor.current = false
|
||||
}
|
||||
|
||||
// 检查是否跳过了锚点时间,如果是则触发锚点
|
||||
checkAndTriggerAnchors(currentTime)
|
||||
checkAndTriggerAnchors(time)
|
||||
}
|
||||
|
||||
// 获取当前激活的锚点
|
||||
// ─── 事件处理 ───
|
||||
|
||||
const activeAnchor = activeAnchorId ? anchors.find((a) => a.id === activeAnchorId) : null
|
||||
|
||||
// 统一处理 onEnded:更新播放状态并透传给外部
|
||||
const handleEnded: React.VideoHTMLAttributes<HTMLVideoElement>['onEnded'] = (e) => {
|
||||
setIsPlaying(false)
|
||||
props.onEnded?.(e)
|
||||
@@ -430,55 +403,27 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
const handleSkipClick = (): void => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const currentTime = videoRef.current.currentTime
|
||||
const time = videoRef.current.currentTime
|
||||
const videoDuration = videoRef.current.duration
|
||||
|
||||
// 检查是否有未触发的锚点
|
||||
let hasUntriggeredAnchor = false
|
||||
for (const anchor of anchors) {
|
||||
// 检查是否启用
|
||||
if (anchor.enabled === false) continue
|
||||
// 检查是否已移除
|
||||
if (removedAnchorIds.has(anchor.id)) continue
|
||||
// 检查 once 逻辑
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue
|
||||
// 检查是否已隐藏
|
||||
if (hiddenAnchorIds.has(anchor.id)) continue
|
||||
// 检查锚点时间是否在当前时间和视频结束之间
|
||||
if (anchor.time >= currentTime && anchor.time <= videoDuration) {
|
||||
hasUntriggeredAnchor = true
|
||||
break
|
||||
}
|
||||
// 找到第一个可触发的锚点
|
||||
const nextAnchor = anchors.find((anchor) => {
|
||||
if (!isAnchorTriggerable(anchor)) return false
|
||||
return anchor.time >= time && anchor.time <= videoDuration
|
||||
})
|
||||
|
||||
if (nextAnchor) {
|
||||
videoRef.current.currentTime = nextAnchor.time
|
||||
setTimeout(() => {
|
||||
checkAndTriggerAnchors(nextAnchor.time)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果有未触发的锚点,跳转到第一个未触发的锚点时间并触发
|
||||
if (hasUntriggeredAnchor) {
|
||||
// 找到第一个未触发的锚点
|
||||
const nextAnchor = anchors.find((anchor) => {
|
||||
if (anchor.enabled === false) return false
|
||||
if (removedAnchorIds.has(anchor.id)) return false
|
||||
if (anchor.once && triggeredOnceIds.has(anchor.id)) return false
|
||||
if (hiddenAnchorIds.has(anchor.id)) return false
|
||||
return anchor.time >= currentTime && anchor.time <= videoDuration
|
||||
})
|
||||
|
||||
if (nextAnchor) {
|
||||
// 跳转到锚点时间
|
||||
videoRef.current.currentTime = nextAnchor.time
|
||||
// 触发锚点(通过 checkAndTriggerAnchors)
|
||||
setTimeout(() => {
|
||||
checkAndTriggerAnchors(nextAnchor.time)
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有未触发的锚点,执行原来的跳过逻辑
|
||||
// 没有未触发的锚点,执行跳过逻辑
|
||||
videoRef.current.pause()
|
||||
onSkip?.()
|
||||
// 触发与自然结束相同的逻辑
|
||||
if (props.onEnded) {
|
||||
// 调用时不关心事件内容,保持类型兼容
|
||||
;(props.onEnded as (e: any) => void)({} as any)
|
||||
}
|
||||
}
|
||||
@@ -495,68 +440,27 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
resetControlsTimer()
|
||||
}
|
||||
|
||||
// 进度条点击/拖拽
|
||||
// 进度条点击
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// 如果正在拖拽,不处理点击事件
|
||||
if (isDragging) return
|
||||
if (isHandlingAnchor.current) return
|
||||
if (shouldStopAtAnchorRef.current) return
|
||||
if (!videoRef.current || !progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
const targetTime = percent * duration
|
||||
|
||||
// 检查目标时间之前是否有未触发的锚点
|
||||
const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
|
||||
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
|
||||
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
|
||||
const shouldStopAtAnchor =
|
||||
nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
|
||||
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
|
||||
|
||||
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
|
||||
if (shouldStopAtAnchor && nearestAnchor) {
|
||||
shouldStopAtAnchorRef.current = {
|
||||
time: finalTime,
|
||||
anchorId: nearestAnchor.id,
|
||||
}
|
||||
// 立即暂停视频
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
shouldStopAtAnchorRef.current = null
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = finalTime
|
||||
seekWithAnchorGuard(percent * duration)
|
||||
resetControlsTimer()
|
||||
}
|
||||
|
||||
// 进度条拖拽开始
|
||||
const handleProgressMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
if (isHandlingAnchor.current) return
|
||||
setIsDragging(true)
|
||||
if (!videoRef.current || !progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const targetTime = percent * duration
|
||||
|
||||
// 检查目标时间之前是否有未触发的锚点
|
||||
const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
|
||||
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
|
||||
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
|
||||
const shouldStopAtAnchor =
|
||||
nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
|
||||
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
|
||||
|
||||
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
|
||||
if (shouldStopAtAnchor && nearestAnchor) {
|
||||
shouldStopAtAnchorRef.current = {
|
||||
time: finalTime,
|
||||
anchorId: nearestAnchor.id,
|
||||
}
|
||||
// 立即暂停视频
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
shouldStopAtAnchorRef.current = null
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = finalTime
|
||||
seekWithAnchorGuard(percent * duration)
|
||||
}
|
||||
|
||||
// 全局鼠标移动和释放事件处理拖拽
|
||||
@@ -564,37 +468,15 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
if (!isDragging) return
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isHandlingAnchor.current) return
|
||||
if (!videoRef.current || !progressRef.current) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const targetTime = percent * duration
|
||||
|
||||
// 检查目标时间之前是否有未触发的锚点
|
||||
const nearestAnchor = findNearestAnchorBeforeTime(targetTime)
|
||||
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
|
||||
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
|
||||
const shouldStopAtAnchor =
|
||||
nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1
|
||||
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime
|
||||
|
||||
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
|
||||
if (shouldStopAtAnchor && nearestAnchor) {
|
||||
shouldStopAtAnchorRef.current = {
|
||||
time: finalTime,
|
||||
anchorId: nearestAnchor.id,
|
||||
}
|
||||
// 立即暂停视频
|
||||
videoRef.current.pause()
|
||||
} else {
|
||||
shouldStopAtAnchorRef.current = null
|
||||
}
|
||||
|
||||
videoRef.current.currentTime = finalTime
|
||||
seekWithAnchorGuard(percent * duration)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
// 拖拽结束后,handleSeeked 会自动处理锚点触发
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
@@ -604,7 +486,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, duration, checkAndTriggerAnchors, findNearestAnchorBeforeTime])
|
||||
}, [isDragging, duration, seekWithAnchorGuard])
|
||||
|
||||
// 音量控制
|
||||
const toggleMute = () => {
|
||||
@@ -822,9 +704,9 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 锚点渲染层 - Safari 兼容:直接使用 pointer-events-auto */}
|
||||
{/* 锚点渲染层 */}
|
||||
{activeAnchor && (
|
||||
<div className="absolute inset-0 z-10" style={{ pointerEvents: 'auto' }}>
|
||||
<div className="absolute inset-0 z-20" style={{ pointerEvents: 'auto' }}>
|
||||
{activeAnchor.render({
|
||||
resume: createResume(activeAnchor),
|
||||
unmute,
|
||||
|
||||
Reference in new issue
Block a user