chore: 更新素材资源和组件优化
- 更新陈云、鲁迅、聂耳等故居的展品图片素材 - 更新拼图细节图片 - 优化学习地图底图和详情图 - 更新音频素材 - 优化各故居 store 和页面组件
This commit is contained in:
1 parent
165374b986
commit
d445f81e60
159 files changed
+4238
-654
No files matched your search
@@ -62,7 +62,7 @@ const ContinueLearningModal = ({
|
||||
</div>
|
||||
|
||||
{/* 正文 */}
|
||||
<p className="mt-10 max-w-[720px] text-center text-4xl font-medium leading-relaxed tracking-wide text-[#5c3a1e]">
|
||||
<p className="indent-18 mt-10 max-w-[720px] text-4xl font-medium leading-relaxed tracking-wide text-[#5c3a1e]">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ const ErrorAlert = ({
|
||||
{/* 标题和副标题容器 - 带深色背景条 */}
|
||||
<div className="w-full bg-black/70 px-20 py-20 flex flex-col items-center gap-4">
|
||||
{/* 副标题 */}
|
||||
<div className="text-white text-5xl font-medium tracking-widest leading-18">{text}</div>
|
||||
<div className="text-white text-5xl font-medium tracking-widest leading-18 indent-26">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 继续按钮 */}
|
||||
|
||||
@@ -136,7 +136,7 @@ const BasketQuizPanel = ({
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[90px] px-10 flex items-center justify-center',
|
||||
'relative min-h-[90px] px-10 flex items-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)
|
||||
@@ -147,7 +147,7 @@ const BasketQuizPanel = ({
|
||||
{!(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">
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] leading-snug">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ const BasketQuizPanel = ({
|
||||
renderDragging={(option) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-h-[60px] px-10 flex items-center justify-center',
|
||||
'relative min-h-[60px] px-10 flex items-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]',
|
||||
@@ -163,7 +163,7 @@ const BasketQuizPanel = ({
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] leading-snug whitespace-nowrap">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,15 @@ const DragQuizPanel = ({
|
||||
blankSeparators,
|
||||
value,
|
||||
error,
|
||||
feedbackType = 'error',
|
||||
onChange,
|
||||
onSubmit,
|
||||
submitText = '提交',
|
||||
disabled = false,
|
||||
}: DragQuizPanelProps) => {
|
||||
const isComplete = blanks.every((blank) =>
|
||||
value.some((result) => result.blankId === blank.id && result.optionValue !== '')
|
||||
)
|
||||
// 跟踪之前的 visible 状态
|
||||
const prevVisibleRef = useRef(visible)
|
||||
|
||||
@@ -62,7 +69,7 @@ const DragQuizPanel = ({
|
||||
{filledValue ? (
|
||||
<span className="text-[#f0be75] font-semibold flex items-center gap-3">
|
||||
<span className="text-[28px]">{filledValue}</span>
|
||||
<button
|
||||
{/* <button
|
||||
type="button"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-[#8b4513]/80 hover:bg-[#a0522d] text-white text-sm transition-all hover:scale-110 shadow-lg"
|
||||
onClick={(e) => {
|
||||
@@ -71,7 +78,7 @@ const DragQuizPanel = ({
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</button> */}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/40 text-[24px] font-light">____</span>
|
||||
@@ -87,48 +94,63 @@ const DragQuizPanel = ({
|
||||
})}
|
||||
{questionSuffix && <span>{questionSuffix}</span>}
|
||||
{/* 答案反馈 */}
|
||||
{error && <div className="mt-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
{error && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-5 text-center text-[26px]',
|
||||
feedbackType === 'success' ? 'text-[#b7f7d1]' : 'text-[#fcbe53]'
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 使用 DragFill 组件 */}
|
||||
<div className="w-[1500px] mt-16">
|
||||
{/* 选项区域 */}
|
||||
<div className="w-full max-w-[1300px] mt-10">
|
||||
<DragFill
|
||||
options={options}
|
||||
blanks={blanks}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
hideLabels
|
||||
showSubmitButton={false}
|
||||
layout="vertical"
|
||||
className="w-full"
|
||||
className="w-full h-auto!"
|
||||
optionClassName="grid grid-cols-2 gap-5"
|
||||
renderOption={(option, isUsed) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-[110px] px-10 flex items-center justify-center',
|
||||
'rounded-[32px] transition-all duration-300',
|
||||
'text-[32px] font-bold border-3 shadow-[0_8px_32px_rgba(0,0,0,0.3)]',
|
||||
'px-10 py-4 flex items-center justify-center',
|
||||
'rounded-2xl transition-all duration-300',
|
||||
'text-[30px] font-bold border-2',
|
||||
'shadow-[0_4px_16px_rgba(0,0,0,0.25)]',
|
||||
isUsed
|
||||
? '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]'
|
||||
: 'border-[#d4c5a9] bg-linear-to-br from-[#4a3f35] to-[#3a2f25] text-[#f5e6d3] cursor-move hover:border-[#f0be75] hover:shadow-[0_8px_28px_rgba(240,190,117,0.6)] hover:scale-[1.02]'
|
||||
)}
|
||||
>
|
||||
{!isUsed && (
|
||||
<div className="absolute inset-0 rounded-[32px] bg-linear-to-br from-[#d4c5a9]/20 to-transparent pointer-events-none" />
|
||||
)}
|
||||
<span className="relative z-10 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)]">{option.label}</span>
|
||||
</div>
|
||||
)}
|
||||
renderBlank={(_blank) => {
|
||||
// 返回一个隐藏元素,实际的空白区域在题干中
|
||||
// 但需要通过 data-blank-id 让 DragFill 能找到它
|
||||
return <div className="hidden" />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !isComplete}
|
||||
onClick={() => onSubmit?.(value)}
|
||||
className="mt-8 px-24 py-3 text-[28px] font-medium text-white bg-[#c2995f] rounded-[40px] transition-all duration-200 hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg"
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import SuccessAlert from '@/components/layout/alert/success-alert'
|
||||
import type { ErrorCorrectionQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* ErrorCorrectionQuizPanel - 纠错题面板
|
||||
* 横向展示多个文本区块,用户点击找出其中的错误
|
||||
*/
|
||||
const ErrorCorrectionQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
introText,
|
||||
blocks,
|
||||
errorCount,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
}: ErrorCorrectionQuizPanelProps) => {
|
||||
// 记录用户点击过的区块
|
||||
const [clickedBlocks, setClickedBlocks] = useState<Set<number>>(new Set())
|
||||
// 记录找到的错误数量
|
||||
const [foundErrors, setFoundErrors] = useState(0)
|
||||
// 当前显示在题干区域的反馈
|
||||
const [feedbackBlockId, setFeedbackBlockId] = useState<number | null>(null)
|
||||
// 临时高亮的错误选项
|
||||
const [wrongFlashBlockId, setWrongFlashBlockId] = useState<number | null>(null)
|
||||
const wrongFlashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// 是否已完成
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
|
||||
// 重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (wrongFlashTimerRef.current) {
|
||||
clearTimeout(wrongFlashTimerRef.current)
|
||||
wrongFlashTimerRef.current = null
|
||||
}
|
||||
setClickedBlocks(new Set())
|
||||
setFoundErrors(0)
|
||||
setFeedbackBlockId(null)
|
||||
setWrongFlashBlockId(null)
|
||||
setIsCompleted(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wrongFlashTimerRef.current) {
|
||||
clearTimeout(wrongFlashTimerRef.current)
|
||||
wrongFlashTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 处理区块点击
|
||||
const handleBlockClick = (blockId: number) => {
|
||||
if (disabled || isCompleted || clickedBlocks.has(blockId)) return
|
||||
|
||||
const block = blocks.find((b) => b.id === blockId)
|
||||
if (!block) return
|
||||
|
||||
const newClickedBlocks = new Set(clickedBlocks)
|
||||
setFeedbackBlockId(blockId)
|
||||
if (wrongFlashTimerRef.current) {
|
||||
clearTimeout(wrongFlashTimerRef.current)
|
||||
wrongFlashTimerRef.current = null
|
||||
}
|
||||
setWrongFlashBlockId(null)
|
||||
|
||||
// 如果点击的是错误区块,增加找到的错误数
|
||||
if (block.isError) {
|
||||
newClickedBlocks.add(blockId)
|
||||
setClickedBlocks(newClickedBlocks)
|
||||
const newFoundErrors = foundErrors + 1
|
||||
setFoundErrors(newFoundErrors)
|
||||
|
||||
// 检查是否找全了所有错误
|
||||
if (newFoundErrors === errorCount) {
|
||||
setIsCompleted(true)
|
||||
}
|
||||
} else {
|
||||
setWrongFlashBlockId(blockId)
|
||||
wrongFlashTimerRef.current = setTimeout(() => {
|
||||
setWrongFlashBlockId(null)
|
||||
wrongFlashTimerRef.current = null
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取区块的显示状态
|
||||
const getBlockStatus = (blockId: number) => {
|
||||
if (!clickedBlocks.has(blockId)) return 'unclicked'
|
||||
|
||||
const block = blocks.find((b) => b.id === blockId)
|
||||
if (!block) return 'unclicked'
|
||||
|
||||
return block.isError ? 'found-error' : 'wrong-selection'
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const feedbackBlock = blocks.find((block) => block.id === feedbackBlockId)
|
||||
const feedbackStatus = feedbackBlock?.isError ? 'found-error' : 'wrong-selection'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black/45" style={{ zIndex }}>
|
||||
<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-[#130f0b]/95 px-20 py-9 shadow-[0_20px_60px_rgba(240,190,117,0.75)] border-4 border-[#d4c5a9] mb-8">
|
||||
<div className="flex items-start gap-6">
|
||||
{/* <div className="shrink-0 mt-1 rounded-full border-2 border-[#fcbe53] bg-[#fcbe53]/15 px-5 py-2 text-[24px] font-bold text-[#fcbe53]">
|
||||
纠错
|
||||
</div> */}
|
||||
<div className="flex-1">
|
||||
<div className="text-[34px] leading-relaxed text-white">
|
||||
{title}
|
||||
<span className="ml-4 inline-flex rounded-full border border-[#fcbe53]/60 bg-[#fcbe53]/15 px-4 py-1 text-[24px] text-[#fcbe53] align-baseline">
|
||||
已找到 {foundErrors} / {errorCount} 处错误
|
||||
</span>
|
||||
</div>
|
||||
{introText && (
|
||||
<div className="indent-13 mt-4 text-[26px] leading-relaxed text-white/75">
|
||||
{introText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedbackBlock && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-6 rounded-2xl border-2 px-8 py-4 text-center text-[26px] leading-relaxed',
|
||||
feedbackStatus === 'found-error'
|
||||
? 'border-[#fcbe53]/70 bg-[#fcbe53]/15 text-[#ffe3a8]'
|
||||
: 'border-white/20 bg-white/10 text-white/85'
|
||||
)}
|
||||
>
|
||||
{/* <span className="mr-3 font-bold">
|
||||
{feedbackStatus === 'found-error' ? '找到疑点:' : '不是疑点:'}
|
||||
</span> */}
|
||||
{feedbackBlock.feedback}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文本区块(横向排列) */}
|
||||
<div className="w-full max-w-[1700px] grid grid-cols-5 gap-4">
|
||||
{blocks.map((block) => {
|
||||
const status = getBlockStatus(block.id)
|
||||
const isClicked = clickedBlocks.has(block.id)
|
||||
const isWrongFlashing = wrongFlashBlockId === block.id
|
||||
|
||||
return (
|
||||
<div key={block.id} className="flex flex-col gap-3">
|
||||
{/* 区块本身 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative px-6 py-6 rounded-[26px] border-4 transition-all duration-300',
|
||||
'text-[24px] leading-relaxed cursor-pointer select-none',
|
||||
'min-h-[210px] flex items-center justify-center',
|
||||
'shadow-[0_10px_26px_rgba(43,28,16,0.25)]',
|
||||
!isClicked &&
|
||||
!isWrongFlashing &&
|
||||
'border-[#d4c5a9] bg-[#f5f0e6] text-[#5c3e2e] hover:border-[#fcbe53] hover:-translate-y-1 hover:shadow-[0_16px_32px_rgba(240,190,117,0.35)]',
|
||||
isWrongFlashing &&
|
||||
'border-white/70 bg-[#2c2925]/90 text-white shadow-[0_0_24px_rgba(255,255,255,0.2)]',
|
||||
status === 'found-error' &&
|
||||
'border-[#fcbe53] bg-linear-to-br from-[#5b321e] to-[#2a1810] text-[#fff3d8] shadow-[0_0_28px_rgba(252,190,83,0.55)]',
|
||||
status === 'wrong-selection' &&
|
||||
'border-white/70 bg-[#2c2925]/90 text-white shadow-[0_0_24px_rgba(255,255,255,0.2)]',
|
||||
(disabled || isCompleted) && !isClicked && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onClick={() => handleBlockClick(block.id)}
|
||||
>
|
||||
{isWrongFlashing && (
|
||||
<div className="absolute -top-4 right-4 rounded-full border-2 border-white/60 bg-[#2c2925] px-4 py-2 text-[20px] font-bold text-white shadow-lg">
|
||||
再看看
|
||||
</div>
|
||||
)}
|
||||
{status === 'found-error' && (
|
||||
<div className="absolute -top-4 right-4 rounded-full border-2 border-[#ffe3a8] bg-[#fcbe53] px-4 py-2 text-[20px] font-bold text-[#5c3218] shadow-lg">
|
||||
已标记
|
||||
</div>
|
||||
)}
|
||||
{status === 'wrong-selection' && (
|
||||
<div className="absolute -top-4 right-4 rounded-full border-2 border-white/60 bg-[#2c2925] px-4 py-2 text-[20px] font-bold text-white shadow-lg">
|
||||
再看看
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="indent-12">{block.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 完成提示 */}
|
||||
<SuccessAlert
|
||||
visible={isCompleted}
|
||||
title="恭喜你,找出了所有错误!"
|
||||
onClose={() => onSubmit?.(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorCorrectionQuizPanel
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SortQuizPanelProps } from './types'
|
||||
|
||||
/**
|
||||
* SortQuizPanel - 排序题面板
|
||||
* 支持拖拽排序,用于让用户按正确顺序排列选项
|
||||
*/
|
||||
const SortQuizPanel = ({
|
||||
visible,
|
||||
zIndex = 10000,
|
||||
title,
|
||||
items,
|
||||
correctOrder,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
onSubmit,
|
||||
onError,
|
||||
disabled = false,
|
||||
submitText = '提交',
|
||||
}: SortQuizPanelProps) => {
|
||||
// 当前排序状态
|
||||
const [currentOrder, setCurrentOrder] = useState<string[]>([])
|
||||
const [error, setError] = useState<string>('')
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
|
||||
// 拖拽状态
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const dragOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 初始化排序(使用items的初始顺序)
|
||||
useEffect(() => {
|
||||
if (visible && items.length > 0) {
|
||||
setCurrentOrder(items.map((item) => item.id))
|
||||
setError('')
|
||||
setIsSubmitted(false)
|
||||
}
|
||||
}, [visible, items])
|
||||
|
||||
const getLocalPoint = useCallback((clientX: number, clientY: number) => {
|
||||
const panelElement = panelRef.current
|
||||
if (!panelElement) return { x: clientX, y: clientY }
|
||||
|
||||
const rect = panelElement.getBoundingClientRect()
|
||||
const scaleX = rect.width / panelElement.offsetWidth || 1
|
||||
const scaleY = rect.height / panelElement.offsetHeight || 1
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) / scaleX,
|
||||
y: (clientY - rect.top) / scaleY,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getLocalRect = useCallback((element: HTMLElement) => {
|
||||
const panelElement = panelRef.current
|
||||
if (!panelElement) return element.getBoundingClientRect()
|
||||
|
||||
const panelRect = panelElement.getBoundingClientRect()
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scaleX = panelRect.width / panelElement.offsetWidth || 1
|
||||
const scaleY = panelRect.height / panelElement.offsetHeight || 1
|
||||
|
||||
return {
|
||||
left: (rect.left - panelRect.left) / scaleX,
|
||||
top: (rect.top - panelRect.top) / scaleY,
|
||||
width: rect.width / scaleX,
|
||||
height: rect.height / scaleY,
|
||||
right: (rect.right - panelRect.left) / scaleX,
|
||||
bottom: (rect.bottom - panelRect.top) / scaleY,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateDragOverIndex = useCallback((clientX: number, clientY: number) => {
|
||||
for (let i = 0; i < itemRefs.current.length; i++) {
|
||||
const element = itemRefs.current[i]
|
||||
if (!element) continue
|
||||
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
setDragOverIndex(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
setDragOverIndex(null)
|
||||
}, [])
|
||||
|
||||
// 处理拖拽开始(Pointer Events 同时兼容鼠标、触摸和手写笔)
|
||||
const handlePointerDown = (e: React.PointerEvent, index: number) => {
|
||||
if (disabled || isSubmitted || e.button !== 0) return
|
||||
|
||||
const itemElement = itemRefs.current[index]
|
||||
if (!itemElement) return
|
||||
|
||||
const localPoint = getLocalPoint(e.clientX, e.clientY)
|
||||
const localRect = getLocalRect(itemElement)
|
||||
dragOffsetRef.current = {
|
||||
x: localPoint.x - localRect.left,
|
||||
y: localPoint.y - localRect.top,
|
||||
}
|
||||
|
||||
setDraggingIndex(index)
|
||||
setDragPosition(localPoint)
|
||||
updateDragOverIndex(e.clientX, e.clientY)
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
// 处理放下
|
||||
const handleDrop = useCallback(() => {
|
||||
if (draggingIndex === null) return
|
||||
|
||||
if (dragOverIndex !== null && dragOverIndex !== draggingIndex) {
|
||||
const newOrder = [...currentOrder]
|
||||
const removed = newOrder[draggingIndex]
|
||||
if (removed === undefined) return
|
||||
newOrder.splice(draggingIndex, 1)
|
||||
newOrder.splice(dragOverIndex, 0, removed)
|
||||
setCurrentOrder(newOrder)
|
||||
}
|
||||
|
||||
setDraggingIndex(null)
|
||||
setDragOverIndex(null)
|
||||
setDragPosition(null)
|
||||
}, [currentOrder, dragOverIndex, draggingIndex])
|
||||
|
||||
// 处理拖拽移动
|
||||
useEffect(() => {
|
||||
if (draggingIndex === null) return
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
setDragPosition(getLocalPoint(e.clientX, e.clientY))
|
||||
updateDragOverIndex(e.clientX, e.clientY)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
handleDrop()
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove, { passive: false })
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
document.addEventListener('pointercancel', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
document.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
}, [draggingIndex, getLocalPoint, handleDrop, updateDragOverIndex])
|
||||
|
||||
// 提交验证
|
||||
const handleSubmit = () => {
|
||||
if (disabled || isSubmitted) return
|
||||
|
||||
const isCorrect = JSON.stringify(currentOrder) === JSON.stringify(correctOrder)
|
||||
|
||||
if (isCorrect) {
|
||||
setError('')
|
||||
setIsSubmitted(true)
|
||||
onSubmit?.(currentOrder, true)
|
||||
} else {
|
||||
setError(errorMessage || '排序不正确,请重新调整顺序')
|
||||
onError?.(errorMessage || '排序不正确,请重新调整顺序')
|
||||
onSubmit?.(currentOrder, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40"
|
||||
style={{ zIndex }}
|
||||
>
|
||||
<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-5 text-center text-[26px] text-[#fcbe53]">{error}</div>}
|
||||
{isSubmitted && successMessage && (
|
||||
<div className="mt-5 text-center text-[26px] text-[#4ade80]">{successMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 排序选项区域 */}
|
||||
<div className="w-full max-w-[1500px] flex flex-col gap-4 mb-8">
|
||||
{currentOrder.map((itemId, index) => {
|
||||
const item = items.find((i) => i.id === itemId)
|
||||
if (!item) return null
|
||||
|
||||
const isDragging = draggingIndex === index
|
||||
const isDragOver = dragOverIndex === index && dragOverIndex !== draggingIndex
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
ref={(el) => {
|
||||
itemRefs.current[index] = el
|
||||
}}
|
||||
className={cn(
|
||||
'relative px-8 py-6 rounded-2xl border-3 transition-all duration-200 touch-none',
|
||||
'text-[28px] leading-relaxed select-none',
|
||||
disabled || isSubmitted
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-grab active:cursor-grabbing',
|
||||
isDragging
|
||||
? 'opacity-50 scale-95'
|
||||
: isDragOver
|
||||
? 'border-[#f0be75] bg-[#f0be75]/20 scale-105'
|
||||
: 'border-[#d4c5a9] bg-[#d7b888] text-[#5c3e2e] hover:border-[#f0be75] hover:shadow-lg'
|
||||
)}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 拖拽手柄图标 */}
|
||||
<div className="flex flex-col gap-1 opacity-50">
|
||||
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
|
||||
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
|
||||
<div className="w-6 h-1 bg-[#5c3e2e] rounded-full" />
|
||||
</div>
|
||||
<div className="flex-1">{item.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{!isSubmitted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'px-16 py-4 rounded-2xl text-[32px] font-bold transition-all duration-300',
|
||||
'shadow-[0_8px_24px_rgba(0,0,0,0.3)]',
|
||||
disabled
|
||||
? 'bg-gray-400 text-gray-600 cursor-not-allowed'
|
||||
: 'bg-[#f0be75] text-[#5c3e2e] hover:bg-[#e0ae65] hover:scale-105 hover:shadow-[0_12px_32px_rgba(240,190,117,0.6)]'
|
||||
)}
|
||||
>
|
||||
{submitText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 拖拽预览 */}
|
||||
{draggingIndex !== null && dragPosition && (
|
||||
<div
|
||||
className="fixed w-full max-w-[1500px] pointer-events-none z-[10001]"
|
||||
style={{
|
||||
left: `${dragPosition.x - dragOffsetRef.current.x}px`,
|
||||
top: `${dragPosition.y - dragOffsetRef.current.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className="px-8 py-6 rounded-2xl border-3 border-[#f0be75] bg-[#d7b888] text-[#5c3e2e] text-[28px] shadow-2xl opacity-90">
|
||||
{items.find((i) => i.id === currentOrder[draggingIndex])?.label}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortQuizPanel
|
||||
@@ -5,6 +5,8 @@ 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 { default as SortQuizPanel } from './SortQuizPanel'
|
||||
export { default as ErrorCorrectionQuizPanel } from './ErrorCorrectionQuizPanel'
|
||||
export type {
|
||||
SingleChoiceQuizPanelProps,
|
||||
MultipleChoiceQuizPanelProps,
|
||||
@@ -15,6 +17,10 @@ export type {
|
||||
SelectFillQuizPanelProps,
|
||||
SelectFillBlank,
|
||||
SelectFillResult,
|
||||
SortQuizPanelProps,
|
||||
SortQuizItem,
|
||||
ErrorCorrectionQuizPanelProps,
|
||||
ErrorCorrectionBlock,
|
||||
} from './types'
|
||||
|
||||
// 保留旧组件以保持向后兼容
|
||||
|
||||
@@ -37,6 +37,12 @@ export interface DragQuizPanelProps {
|
||||
feedbackType?: 'success' | 'error'
|
||||
/** 值变化回调 */
|
||||
onChange: (value: DragFillResult[]) => void
|
||||
/** 所有空白填满后点击提交的回调 */
|
||||
onSubmit?: (value: DragFillResult[]) => void
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string
|
||||
/** 是否禁用拖拽和提交 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** 单选题面板配置 */
|
||||
@@ -192,6 +198,72 @@ export interface SelectFillQuizPanelProps {
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** 排序题项 */
|
||||
export interface SortQuizItem {
|
||||
/** 唯一标识 */
|
||||
id: string
|
||||
/** 显示文本 */
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 排序题面板配置 */
|
||||
export interface SortQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string
|
||||
/** 待排序的选项列表(初始顺序) */
|
||||
items: SortQuizItem[]
|
||||
/** 正确的排序顺序(id数组) */
|
||||
correctOrder: string[]
|
||||
/** 错误提示信息 */
|
||||
errorMessage?: string
|
||||
/** 成功提示信息(可选) */
|
||||
successMessage?: string
|
||||
/** 提交回调,返回用户排序和是否正确 */
|
||||
onSubmit?: (order: string[], isCorrect: boolean) => void
|
||||
/** 错误回调(可选) */
|
||||
onError?: (error: string) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 提交按钮文本 */
|
||||
submitText?: string
|
||||
}
|
||||
|
||||
/** 纠错题区块 */
|
||||
export interface ErrorCorrectionBlock {
|
||||
/** 区块ID */
|
||||
id: number
|
||||
/** 区块文本内容 */
|
||||
text: string
|
||||
/** 是否是错误 */
|
||||
isError: boolean
|
||||
/** 点击后的反馈文本 */
|
||||
feedback: string
|
||||
}
|
||||
|
||||
/** 纠错题面板配置 */
|
||||
export interface ErrorCorrectionQuizPanelProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** z-index 层级 */
|
||||
zIndex?: number
|
||||
/** 题目 */
|
||||
title: string
|
||||
/** 情景介绍文本(可选) */
|
||||
introText?: string
|
||||
/** 文本区块列表 */
|
||||
blocks: ErrorCorrectionBlock[]
|
||||
/** 需要找出的错误数量 */
|
||||
errorCount: number
|
||||
/** 提交回调(找到所有错误时自动调用) */
|
||||
onSubmit?: (isCorrect: boolean) => void
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
|
||||
export interface QuizPanelProps {
|
||||
visible: boolean
|
||||
|
||||
@@ -237,7 +237,7 @@ const LetterTemplate = ({
|
||||
} else if (/\d/.test(value)) {
|
||||
showHint('不能包含数字')
|
||||
} else if (/[\p{P}\p{S}]/u.test(value)) {
|
||||
showHint('不能包含标点符号')
|
||||
showHint('不能包含特殊符号')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ const LetterTemplate = ({
|
||||
} else if (/\d/.test(value)) {
|
||||
showHint('不能包含数字')
|
||||
} else if (/[\p{P}\p{S}]/u.test(value)) {
|
||||
showHint('不能包含标点符号')
|
||||
showHint('不能包含特殊符号')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,14 +549,10 @@ const LetterTemplate = ({
|
||||
/>
|
||||
|
||||
<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 className="flex flex-col items-center text-white text-6xl font-bold leading-none gap-[20px]">
|
||||
{Array.from(title).map((char, index) => (
|
||||
<span key={`title-${index}`}>{char}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,16 +47,21 @@ const fields: Record<number, LetterFieldConfig> = {
|
||||
3: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[180px]',
|
||||
placeholder: '结合所学专业',
|
||||
widthClassName: 'w-[120px]',
|
||||
readOnlyFallback: '专业',
|
||||
},
|
||||
4: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
widthClassName: 'w-[180px]',
|
||||
placeholder: '结合所学专业',
|
||||
readOnlyFallback: '实干报国',
|
||||
},
|
||||
5: {
|
||||
type: 'input',
|
||||
maxLength: 12,
|
||||
widthClassName: 'w-[120px]',
|
||||
},
|
||||
}
|
||||
|
||||
const LetterToChenyun = ({
|
||||
|
||||
@@ -575,8 +575,14 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
|
||||
// 计算进度百分比
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
const disableFirstWatchVideoGating =
|
||||
|
||||
// 视频首次观看限制:env 默认值可被调试面板运行时覆盖
|
||||
const envDisableGating =
|
||||
import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
||||
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
|
||||
const disableFirstWatchVideoGating =
|
||||
sessionOverride !== null ? sessionOverride === 'disabled' : envDisableGating
|
||||
|
||||
const canShowSkipButton = !skipKey || disableFirstWatchVideoGating || isVideoCompleted
|
||||
const canShowControls = !controlsOnReplay || disableFirstWatchVideoGating || isVideoCompleted
|
||||
const shouldShowReadonlyProgress =
|
||||
@@ -642,6 +648,43 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
||||
style={{ transform: `scaleX(${progressPercent / 100})` }}
|
||||
/>
|
||||
</div>
|
||||
{/* 锚点标记:纵向竖标 + 圆点 */}
|
||||
{visibleAnchors.map((anchor) => {
|
||||
const anchorPercent = duration > 0 ? (anchor.time / duration) * 100 : 0
|
||||
const isPassed = currentTime >= anchor.time
|
||||
return (
|
||||
<div
|
||||
key={anchor.id}
|
||||
className="absolute bottom-0 flex flex-col items-center"
|
||||
style={{
|
||||
left: `${anchorPercent}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
title={`题目: ${formatTime(anchor.time)}`}
|
||||
>
|
||||
{/* 竖线 */}
|
||||
<div
|
||||
className="w-px"
|
||||
style={{
|
||||
height: '14px',
|
||||
background: isPassed
|
||||
? 'linear-gradient(to top, #fbbf24, transparent)'
|
||||
: 'linear-gradient(to top, rgba(255,255,255,0.7), transparent)',
|
||||
}}
|
||||
/>
|
||||
{/* 圆点 */}
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background: isPassed ? '#fbbf24' : '#ffffff',
|
||||
boxShadow: isPassed
|
||||
? '0 0 8px rgba(251,191,36,0.8)'
|
||||
: '0 0 5px rgba(255,255,255,0.6)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 跳过按钮 */}
|
||||
|
||||
Reference in new issue
Block a user