fix: 修复开发/测试包模拟进度被易班同步覆盖的问题
进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6d40e19346
commit
49ecabbf3a
155 files changed
+1956
-724
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { Children, useEffect, useState } from 'react'
|
||||
import { Children, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Image } from '@/components'
|
||||
import { cn, getStaticImage } from '@/lib/utils'
|
||||
@@ -95,6 +95,8 @@ export const StudyGuideModal = ({
|
||||
audioChannelId,
|
||||
}: StudyGuideModalProps) => {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const frameRef = useRef<HTMLDivElement>(null)
|
||||
const [frameHeight, setFrameHeight] = useState(0)
|
||||
const { scale, offsetX, offsetY, DESIGN_WIDTH, DESIGN_HEIGHT } = useScale()
|
||||
const videoContext = useVideoContextSafe()
|
||||
const { currentSrc, play, stop } = useAudioManager(
|
||||
@@ -141,6 +143,18 @@ export const StudyGuideModal = ({
|
||||
}
|
||||
}, [mounted, canPlayAudio, audioSrc, currentSrc, stop])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || !visible || !frameRef.current) return
|
||||
|
||||
const updateFrameHeight = () => setFrameHeight(frameRef.current?.offsetHeight ?? 0)
|
||||
updateFrameHeight()
|
||||
|
||||
const observer = new ResizeObserver(updateFrameHeight)
|
||||
observer.observe(frameRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [mounted, visible, children])
|
||||
|
||||
if (!visible || !mounted) return null
|
||||
|
||||
const handleMaskClick = () => {
|
||||
@@ -158,55 +172,144 @@ export const StudyGuideModal = ({
|
||||
|
||||
const contentChildren = renderIndentedContent(children, firstLineIndent)
|
||||
|
||||
const fillHeight = Math.max(467.43, frameHeight - 74)
|
||||
const fillPath = [
|
||||
'M76.22,0',
|
||||
'L1658,0.3',
|
||||
'C1658,0.3 1638.88,37.38 1669.5,69.87',
|
||||
'C1700.12,102.36 1733.89,91.14 1733.89,91.14',
|
||||
`L1733.54,${fillHeight - 91.14}`,
|
||||
`C1733.54,${fillHeight - 91.14} 1699.76,${fillHeight - 102.35} 1669.15,${fillHeight - 69.87}`,
|
||||
`C1638.54,${fillHeight - 37.38} 1657.65,${fillHeight - 0.3} 1657.65,${fillHeight - 0.3}`,
|
||||
`L75.89,${fillHeight}`,
|
||||
`C75.89,${fillHeight} 95,${fillHeight - 37.08} 64.39,${fillHeight - 69.57}`,
|
||||
`C33.78,${fillHeight - 102.06} 0,${fillHeight - 90.84} 0,${fillHeight - 90.84}`,
|
||||
'L0.33,90.84',
|
||||
'C0.33,90.84 34.11,102.05 64.72,69.57',
|
||||
'C95.34,37.08 76.22,0 76.22,0',
|
||||
'Z',
|
||||
].join(' ')
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center"
|
||||
style={{ zIndex, pointerEvents: 'auto' }}
|
||||
>
|
||||
<div className="fixed inset-0" style={{ zIndex, pointerEvents: 'auto' }}>
|
||||
{/* 背景遮罩 */}
|
||||
<div className="absolute inset-0 bg-black/60" onClick={handleMaskClick} />
|
||||
|
||||
{/* 引导内容 */}
|
||||
<div className="relative w-[1700px] h-[760px] pointer-events-none pt-65">
|
||||
{/* 引导框背景 */}
|
||||
<div className={cn('absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2')}>
|
||||
{/* 标题 */}
|
||||
{_title &&
|
||||
(_title === '温馨提示' ? (
|
||||
<img
|
||||
src={getStaticImage('components/guide/title')}
|
||||
alt={_title}
|
||||
className="pointer-events-none absolute left-1/2 -top-5 z-20 -translate-x-1/2"
|
||||
/>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute left-1/2 top-6 z-20 -translate-x-1/2 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
|
||||
{_title}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 叶子 */}
|
||||
<img
|
||||
src={getStaticImage('components/guide/引导框')}
|
||||
alt="学习引导背景"
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
src={getStaticImage('components/guide/竹叶')}
|
||||
alt=""
|
||||
className="pointer-events-none absolute left-10 top-16 z-20"
|
||||
/>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 w-[1480px] flex items-center justify-center mx-auto text-white pointer-events-auto',
|
||||
ailgn === 'left' && 'justify-start',
|
||||
ailgn === 'right' && 'justify-end'
|
||||
)}
|
||||
>
|
||||
{/* {title && (
|
||||
<div className="mt-40 text-5xl font-bold text-[#ffe7b2] tracking-[12px]">
|
||||
{title}
|
||||
</div>
|
||||
)} */}
|
||||
{children && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-4xl leading-relaxed whitespace-pre-line overflow-y-auto custom-scrollbar max-h-[300px]',
|
||||
'[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
|
||||
)}
|
||||
style={{
|
||||
marginTop: verticalAlign === 'center' ? '85px' : '',
|
||||
paddingLeft:
|
||||
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
|
||||
}}
|
||||
{/* 星星 */}
|
||||
<img
|
||||
src={getStaticImage('components/guide/star')}
|
||||
alt=""
|
||||
className="pointer-events-none absolute left-1/2 top-20 z-20 -translate-x-1/2"
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<div ref={frameRef} className="relative z-10 w-[1818px] text-white pointer-events-auto">
|
||||
{/* 中间不规则渐变填充 */}
|
||||
<div
|
||||
className="absolute z-0 pointer-events-none"
|
||||
style={{
|
||||
left: '38px',
|
||||
top: '28px',
|
||||
width: 'calc(100% - 80px)',
|
||||
height: 'calc(100% - 54px)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
viewBox={`0 0 1733.87 ${fillHeight}`}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="guide-fill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2={fillHeight}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#a98d80" />
|
||||
<stop offset="100%" stopColor="#c5523d" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#guide-fill)" d={fillPath} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 边框层 */}
|
||||
<div className="absolute inset-0 z-10 pointer-events-none">
|
||||
<img
|
||||
src={getStaticImage('components/guide/frame-top')}
|
||||
alt=""
|
||||
className="absolute left-0 top-0 w-full h-[135px]"
|
||||
/>
|
||||
<img
|
||||
src={getStaticImage('components/guide/frame-middle')}
|
||||
alt=""
|
||||
className="absolute left-[32px] top-[134px]"
|
||||
style={{
|
||||
width: 'calc(100% - 68px)',
|
||||
height: 'calc(100% - 268px)',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src={getStaticImage('components/guide/frame-bottom')}
|
||||
alt=""
|
||||
className="absolute left-0 bottom-0 w-full h-[135px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-20 w-[1520px] flex items-center justify-center mx-auto text-white px-0 py-[130px]',
|
||||
ailgn === 'left' && 'justify-start',
|
||||
ailgn === 'right' && 'justify-end'
|
||||
)}
|
||||
>
|
||||
{children && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-4xl leading-relaxed whitespace-pre-line overflow-visible custom-scrollbar',
|
||||
'[text-shadow:_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4),_0px_0px_10px_rgba(193,85,64,0.4)]'
|
||||
)}
|
||||
style={{
|
||||
paddingLeft:
|
||||
contentPaddingLeft === undefined ? undefined : toCssSize(contentPaddingLeft),
|
||||
}}
|
||||
>
|
||||
{contentChildren}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
<div className="absolute right-15 bottom-8 z-20 pointer-events-auto">
|
||||
<div className="text-right mr-16">
|
||||
<Image
|
||||
src={getStaticImage('components/guide/继续')}
|
||||
alt="继续"
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
interface SyncErrorPromptOptions {
|
||||
title?: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
const SYNC_ERROR_PROMPT_EVENT = 'sync-error-prompt'
|
||||
const shouldShowSyncPrompt = import.meta.env.MODE === 'production'
|
||||
|
||||
const dispatchSyncPrompt = (options: SyncErrorPromptOptions) => {
|
||||
if (!shouldShowSyncPrompt) return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<SyncErrorPromptOptions>(SYNC_ERROR_PROMPT_EVENT, { detail: options })
|
||||
)
|
||||
}
|
||||
|
||||
export const showSyncErrorPrompt = (options: SyncErrorPromptOptions) => {
|
||||
dispatchSyncPrompt(options)
|
||||
}
|
||||
|
||||
export const showYibanUnauthenticatedPrompt = () => {
|
||||
showSyncErrorPrompt({ message: '易班登录状态异常,请先登录易班平台后继续学习。' })
|
||||
}
|
||||
|
||||
export const showYibanSyncErrorPrompt = () => {
|
||||
showSyncErrorPrompt({ message: '学习进度获取失败,请稍后刷新重试。' })
|
||||
}
|
||||
|
||||
export const SyncPromptHost = () => {
|
||||
const [prompt, setPrompt] = useState<SyncErrorPromptOptions | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePrompt = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<SyncErrorPromptOptions>
|
||||
setPrompt(customEvent.detail)
|
||||
}
|
||||
|
||||
window.addEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt)
|
||||
return () => window.removeEventListener(SYNC_ERROR_PROMPT_EVENT, handlePrompt)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const onConfirm = prompt.onConfirm
|
||||
setPrompt(null)
|
||||
onConfirm?.()
|
||||
}, 4500)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [prompt])
|
||||
|
||||
const handleClose = () => {
|
||||
const onConfirm = prompt?.onConfirm
|
||||
setPrompt(null)
|
||||
onConfirm?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[10000]">
|
||||
<AnimatePresence>
|
||||
{prompt && (
|
||||
<motion.div
|
||||
key={prompt.message}
|
||||
className="pointer-events-auto absolute bottom-12 right-12 w-[520px]"
|
||||
initial={{ opacity: 0, x: 40, y: 12, filter: 'blur(2px)' }}
|
||||
animate={{ opacity: 1, x: 0, y: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, x: 32, y: 8, filter: 'blur(2px)' }}
|
||||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-[18px] border-[3px] border-[#c44535] bg-[#f3e8c9]/95 px-8 py-6 shadow-[0_10px_30px_rgba(114,48,35,0.28)]">
|
||||
<div className="pointer-events-none absolute left-0 top-0 h-full w-3 bg-[#c44535]" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-3 text-3xl leading-none text-[#9b2f25] transition-opacity hover:opacity-70"
|
||||
onClick={handleClose}
|
||||
aria-label="关闭提示"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="pr-8">
|
||||
<div className="mb-2 text-3xl font-bold tracking-[4px] text-[#b8322b]">
|
||||
{prompt.title ?? '温馨提示'}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold leading-snug tracking-wide text-[#6d4a24]">
|
||||
{prompt.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
SYNC_LOADING_EVENT,
|
||||
SYNC_LOADING_HIDE_EVENT,
|
||||
type SyncLoadingOptions,
|
||||
} from './syncLoadingApi'
|
||||
|
||||
export const SyncLoadingHost = () => {
|
||||
const [text, setText] = useState('正在同步学习进度...')
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleShow = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<SyncLoadingOptions>
|
||||
setText(customEvent.detail.text ?? '正在同步学习进度...')
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
const handleHide = () => {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
window.addEventListener(SYNC_LOADING_EVENT, handleShow)
|
||||
window.addEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
|
||||
return () => {
|
||||
window.removeEventListener(SYNC_LOADING_EVENT, handleShow)
|
||||
window.removeEventListener(SYNC_LOADING_HIDE_EVENT, handleHide)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
key="sync-loading"
|
||||
className="fixed inset-0 z-10000 flex items-center justify-center bg-black/60"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center gap-8"
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||
>
|
||||
<svg className="h-20 w-20 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="#f4e4bd"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
className="opacity-90"
|
||||
fill="#f4e4bd"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-3xl font-semibold tracking-[6px] text-[#f4e4bd] drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">
|
||||
{text}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +1,11 @@
|
||||
export { GuideSteps, GuideStepsItem } from './GuideSteps'
|
||||
export { StudyGuideModal } from './StudyGuideModal'
|
||||
export { SyncLoadingHost } from './SyncLoadingOverlay'
|
||||
export { showSyncLoading, hideSyncLoading } from './syncLoadingApi'
|
||||
export type { SyncLoadingOptions } from './syncLoadingApi'
|
||||
export {
|
||||
SyncPromptHost,
|
||||
showSyncErrorPrompt,
|
||||
showYibanUnauthenticatedPrompt,
|
||||
showYibanSyncErrorPrompt,
|
||||
} from './SyncErrorPrompt'
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface SyncLoadingOptions {
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const SYNC_LOADING_EVENT = 'sync-loading'
|
||||
export const SYNC_LOADING_HIDE_EVENT = 'sync-loading-hide'
|
||||
|
||||
const dispatchSyncLoading = (detail: SyncLoadingOptions) => {
|
||||
window.dispatchEvent(new CustomEvent<SyncLoadingOptions>(SYNC_LOADING_EVENT, { detail }))
|
||||
}
|
||||
|
||||
export const showSyncLoading = (options?: SyncLoadingOptions) => {
|
||||
dispatchSyncLoading(options ?? {})
|
||||
}
|
||||
|
||||
export const hideSyncLoading = () => {
|
||||
window.dispatchEvent(new Event(SYNC_LOADING_HIDE_EVENT))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { cn, getBackgroundImage } from '@/lib/utils'
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface ShowHintOptions {
|
||||
persistent?: boolean
|
||||
/** 相对中心点微调位置 */
|
||||
offset?: HintPositionOffset
|
||||
/** 自定义层级(默认 60) */
|
||||
/** 自定义层级(默认 9999) */
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEFAULT_OPTIONS: HintState['options'] = {
|
||||
durationMs: 2000,
|
||||
persistent: false,
|
||||
offset: { x: 0, y: 0 },
|
||||
zIndex: 60,
|
||||
zIndex: 9999,
|
||||
}
|
||||
|
||||
let current: HintState = {
|
||||
|
||||
+12
-1
@@ -14,7 +14,18 @@ export type { ImageProps } from './image'
|
||||
export { Title } from './title'
|
||||
|
||||
// Guide components
|
||||
export { GuideSteps, GuideStepsItem, StudyGuideModal } from './guide'
|
||||
export {
|
||||
GuideSteps,
|
||||
GuideStepsItem,
|
||||
StudyGuideModal,
|
||||
SyncLoadingHost,
|
||||
SyncPromptHost,
|
||||
showSyncLoading,
|
||||
hideSyncLoading,
|
||||
showSyncErrorPrompt,
|
||||
showYibanUnauthenticatedPrompt,
|
||||
showYibanSyncErrorPrompt,
|
||||
} from './guide'
|
||||
|
||||
// Exhibit overlay
|
||||
export { ExhibitOverlay } from './exhibit'
|
||||
|
||||
@@ -21,6 +21,10 @@ export interface AlertProps {
|
||||
const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertProps) => {
|
||||
if (!visible) return null
|
||||
|
||||
const titleMatch = title.match(/^(第[一二三四五六七八九十]+幕)[::]["“]?(.+?)["”]?(?:--|——)(.+)$/)
|
||||
const titlePrefix = titleMatch ? `${titleMatch[1]} ${titleMatch[2]}` : undefined
|
||||
const titleContent = titleMatch?.[3] ?? title
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
{/* 蒙版背景 */}
|
||||
@@ -39,12 +43,22 @@ const Alert = ({ title, subtitle, visible = true, onMaskClick, onClose }: AlertP
|
||||
{/* 标题 */}
|
||||
<div
|
||||
className={cn([
|
||||
'absolute -top-10',
|
||||
'text-white text-7xl font-bold',
|
||||
'absolute -top-40 flex flex-col items-center gap-3',
|
||||
'text-white font-bold',
|
||||
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]',
|
||||
])}
|
||||
>
|
||||
{title}
|
||||
{titlePrefix && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[4.8rem] leading-none mb-8'
|
||||
// '[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
|
||||
)}
|
||||
>
|
||||
{titlePrefix}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-6xl leading-none">{titleContent}</span>
|
||||
</div>
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest">{subtitle}</div>
|
||||
|
||||
@@ -257,8 +257,8 @@ const MatchQuizPanel = ({
|
||||
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',
|
||||
'relative px-6 py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[28px] leading-relaxed cursor-pointer',
|
||||
'touch-manipulation',
|
||||
disabled || isMatched ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
isError
|
||||
@@ -283,9 +283,9 @@ const MatchQuizPanel = ({
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
>
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
<span className="text-white text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
@@ -293,9 +293,9 @@ const MatchQuizPanel = ({
|
||||
ref={(el) => {
|
||||
leftDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
className="absolute -right-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
<span className="text-white text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -313,8 +313,8 @@ const MatchQuizPanel = ({
|
||||
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',
|
||||
'relative px-6 py-4 rounded-xl border-2 transition-all duration-200',
|
||||
'text-[28px] leading-relaxed',
|
||||
'touch-manipulation',
|
||||
disabled || isMatched
|
||||
? 'opacity-60 cursor-not-allowed'
|
||||
@@ -345,9 +345,9 @@ const MatchQuizPanel = ({
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-red-400 rounded-full flex items-center justify-center shadow-[0_0_10px_rgba(248,113,113,0.8)]"
|
||||
>
|
||||
<span className="text-white text-[10px] md:text-xs">✕</span>
|
||||
<span className="text-white text-xs">✕</span>
|
||||
</div>
|
||||
)}
|
||||
{!isError && isMatched && (
|
||||
@@ -355,9 +355,9 @@ const MatchQuizPanel = ({
|
||||
ref={(el) => {
|
||||
rightDotRefs.current[item.id] = el
|
||||
}}
|
||||
className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
className="absolute -left-4 top-1/2 -translate-y-1/2 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span className="text-white text-xs md:text-sm">✓</span>
|
||||
<span className="text-white text-sm">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -372,25 +372,14 @@ const MatchQuizPanel = ({
|
||||
style={{ zIndex }}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
|
||||
<div className="text-[24px] md:text-[34px] leading-relaxed text-white">{title}</div>
|
||||
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-8">
|
||||
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-20 py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-8">
|
||||
<div className="text-[34px] leading-relaxed text-white">{title}</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* 移动端 */}
|
||||
<div className="w-full max-w-[1600px] md:hidden flex flex-col gap-4 items-start justify-center">
|
||||
<div className="w-full flex flex-col gap-3">{leftItems.map(renderLeftItem)}</div>
|
||||
<div className="w-full flex flex-col gap-3">{rightItems.map(renderRightItem)}</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面端 */}
|
||||
<div ref={quizAreaRef} className="hidden md:block w-full max-w-[1600px] relative">
|
||||
<div ref={quizAreaRef} className="w-full max-w-[1600px] relative">
|
||||
{/* SVG 覆盖整个题目区域,用绝对像素坐标画线 */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none overflow-visible z-10"
|
||||
@@ -454,10 +443,6 @@ const MatchQuizPanel = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
|
||||
请先点击左侧日期,再点击右侧对应的事件进行连线
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -93,6 +93,7 @@ const LetterTemplate = ({
|
||||
const [signatureName, setSignatureName] = useState('')
|
||||
const [signatureError, setSignatureError] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [letterSent, setLetterSent] = useState(false)
|
||||
const [showSendVideo, setShowSendVideo] = useState(false)
|
||||
const [showReplyVideo, setShowReplyVideo] = useState(false)
|
||||
const [showScene, setShowScene] = useState(false)
|
||||
@@ -167,6 +168,7 @@ const LetterTemplate = ({
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSubmitted(false)
|
||||
setLetterSent(false)
|
||||
setBlankValues([])
|
||||
setBlankErrors([])
|
||||
setSignatureName('')
|
||||
@@ -417,7 +419,7 @@ const LetterTemplate = ({
|
||||
<audio ref={audioRef} src={getPublicPath(audioSrc)} loop className="hidden" />
|
||||
)}
|
||||
|
||||
{!showSendVideo && (
|
||||
{!showSendVideo && !letterSent && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 z-100"
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -428,7 +430,7 @@ const LetterTemplate = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showSendVideo && (
|
||||
{!showSendVideo && !letterSent && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-101 flex items-center justify-center pointer-events-none"
|
||||
initial={{ opacity: 0, scale: 0.5, y: 100 }}
|
||||
@@ -560,6 +562,7 @@ const LetterTemplate = ({
|
||||
onClick={() => {
|
||||
const nextValues = [...blankValues]
|
||||
onSubmit?.(nextValues)
|
||||
setLetterSent(true)
|
||||
setShowSendVideo(true)
|
||||
}}
|
||||
button
|
||||
@@ -596,13 +599,19 @@ const LetterTemplate = ({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{showScene && !showReplyImage && replyImageSrc && (
|
||||
{(showScene || showSendVideo) && replyImageSrc && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-102"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={false}
|
||||
animate={
|
||||
showScene && !showSendVideo && !showReplyImage
|
||||
? { opacity: 1, y: 0 }
|
||||
: { opacity: 0, y: 50 }
|
||||
}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
pointerEvents: showScene && !showSendVideo && !showReplyImage ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getStaticImage(sceneImageSrc)}
|
||||
@@ -636,20 +645,20 @@ const LetterTemplate = ({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{showReplyImage && replyImageSrc && (
|
||||
{(showReplyImage || showScene) && 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 }}
|
||||
initial={false}
|
||||
animate={showReplyImage ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ pointerEvents: showReplyImage ? 'auto' : 'none' }}
|
||||
>
|
||||
<motion.img
|
||||
src={getStaticImage(replyImageSrc)}
|
||||
alt="回信"
|
||||
className="w-full h-full object-contain"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
initial={false}
|
||||
animate={showReplyImage ? { scale: 1, opacity: 1 } : { scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -82,7 +82,7 @@ const LetterToHuangyanpei = ({
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
onAnimationEnd={onAnimationEnd}
|
||||
title="写给黄炎培先生"
|
||||
title="写给黄炎培先生的信"
|
||||
letterContent={letterContent}
|
||||
fields={fields}
|
||||
contentClassName="!text-[25px]"
|
||||
|
||||
@@ -38,7 +38,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '首在立人',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '首在立人',
|
||||
},
|
||||
@@ -46,7 +46,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '批判求真',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '批判求真',
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
@@ -53,7 +53,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
@@ -61,7 +61,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
...commonSelectField,
|
||||
correctValue: '独立自主',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '独立自主',
|
||||
},
|
||||
@@ -76,7 +76,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
],
|
||||
correctValue: '实事求是',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '实事求是',
|
||||
},
|
||||
@@ -91,7 +91,7 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
],
|
||||
correctValue: '群众路线',
|
||||
hints: {
|
||||
error: '请你结合上下文和所学在想想吧!',
|
||||
error: '请你结合上下文和所学再想想吧!',
|
||||
},
|
||||
readOnlyFallback: '群众路线',
|
||||
},
|
||||
|
||||
@@ -367,12 +367,8 @@ const PuzzleSystem = ({
|
||||
'[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]'
|
||||
)}
|
||||
>
|
||||
{/* 空格换行 */}
|
||||
{selectedPieceData.title.split(' ').map((word: string, index: number) => (
|
||||
<div key={index}>
|
||||
{word}
|
||||
{index < selectedPieceData.title.split(' ').length - 1 && ' '}
|
||||
</div>
|
||||
{selectedPieceData.title.split('\n').map((line: string, index: number) => (
|
||||
<div key={index}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user