进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { cn, getStaticImage } from '@/lib/utils'
|
|
import { Image, DragFill } from '@/components'
|
|
import type { DragFillResult } from '@/components'
|
|
import { ErrorAlert, SuccessAlert } from '@/components/layout'
|
|
|
|
export interface TowelSalesDialogProps {
|
|
/** 是否可见 */
|
|
visible?: boolean
|
|
/** 关闭回调 */
|
|
onClose?: () => void
|
|
/** 完成回调 */
|
|
onComplete?: () => void
|
|
}
|
|
|
|
/**
|
|
* 毛巾推销语拖拽弹窗
|
|
* - 左边显示4个可选话术
|
|
* - 右边显示3个毛巾(堆叠显示)
|
|
* - 拖拽话术到毛巾上,点亮对应的毛巾
|
|
* - 3个都点亮后完成
|
|
*/
|
|
const TowelSalesDialog = ({ visible = true, onClose, onComplete }: TowelSalesDialogProps) => {
|
|
const maskSrc = getStaticImage('components/蒙版遮罩')
|
|
const backSrc = getStaticImage('components/关闭')
|
|
|
|
// 毛巾图片资源(未点亮)
|
|
const towel1 = getStaticImage('huangyanpei/reflection/scene4/12_002')
|
|
const towel2 = getStaticImage('huangyanpei/reflection/scene4/12_003')
|
|
const towel3 = getStaticImage('huangyanpei/reflection/scene4/12_004')
|
|
|
|
// 毛巾图片资源(点亮)
|
|
const towel1Lit = getStaticImage('huangyanpei/reflection/scene4/12_002_02')
|
|
const towel2Lit = getStaticImage('huangyanpei/reflection/scene4/12_003_02')
|
|
const towel3Lit = getStaticImage('huangyanpei/reflection/scene4/12_004_03')
|
|
|
|
const [mounted, setMounted] = useState(false)
|
|
const [value, setValue] = useState<DragFillResult[]>([])
|
|
const [errorVisible, setErrorVisible] = useState(false)
|
|
const [successVisible, setSuccessVisible] = useState(false)
|
|
const completedRef = useRef(false)
|
|
|
|
// SSR 兼容
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
// 重置游戏状态
|
|
useEffect(() => {
|
|
if (visible) {
|
|
setValue([])
|
|
completedRef.current = false
|
|
setErrorVisible(false)
|
|
setSuccessVisible(false)
|
|
}
|
|
}, [visible])
|
|
|
|
// 检查是否完成(3个毛巾都点亮)
|
|
useEffect(() => {
|
|
if (value.length === 3 && !completedRef.current) {
|
|
completedRef.current = true
|
|
// 显示成功提示
|
|
setSuccessVisible(true)
|
|
}
|
|
}, [value])
|
|
|
|
const handleClose = useCallback(
|
|
(e: React.MouseEvent<HTMLImageElement>) => {
|
|
e.stopPropagation()
|
|
onClose?.()
|
|
},
|
|
[onClose]
|
|
)
|
|
|
|
// 关闭错误提示
|
|
const handleErrorClose = useCallback(() => {
|
|
setErrorVisible(false)
|
|
}, [])
|
|
|
|
// 关闭成功提示
|
|
const handleSuccessContinue = useCallback(() => {
|
|
setSuccessVisible(false)
|
|
// 关闭提示后,完成并关闭弹窗
|
|
onComplete?.()
|
|
onClose?.()
|
|
}, [onComplete, onClose])
|
|
|
|
// 可选话术选项
|
|
const options = useMemo(
|
|
() => [
|
|
{
|
|
value: 'option1',
|
|
label: 'A.这是中华职校生产的国货,质量比洋货还好!',
|
|
},
|
|
{
|
|
value: 'option2',
|
|
label: 'B.价格便宜,量大从优,诚信经营绝不掺假。',
|
|
},
|
|
{
|
|
value: 'option3',
|
|
label: 'C.买国货就是支持实业救国,为国家出一份力!',
|
|
},
|
|
{
|
|
value: 'option4',
|
|
label: 'D.我只管赚钱,东西好不好不管哦。',
|
|
},
|
|
],
|
|
[]
|
|
)
|
|
|
|
// 转换为 DragFill 的 blanks
|
|
const blanks = useMemo(
|
|
() => [
|
|
{ id: 'towel1', label: '毛巾1' },
|
|
{ id: 'towel2', label: '毛巾2' },
|
|
{ id: 'towel3', label: '毛巾3' },
|
|
],
|
|
[]
|
|
)
|
|
|
|
// 处理拖拽结果变化
|
|
const handleValueChange = useCallback((newValue: DragFillResult[]) => {
|
|
// 检查是否选择了错误的选项(option4)
|
|
const hasOption4 = newValue.some((result) => result.optionValue === 'option4')
|
|
|
|
if (hasOption4) {
|
|
// 显示错误提示
|
|
setErrorVisible(true)
|
|
// 移除 option4,保留其他选项
|
|
const filteredValue = newValue.filter((result) => result.optionValue !== 'option4')
|
|
setValue(filteredValue)
|
|
} else {
|
|
// 限制最多3个
|
|
if (newValue.length <= 3) {
|
|
setValue(newValue)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// 检查毛巾是否被点亮
|
|
const isTowelLit = useCallback(
|
|
(towelId: string) => {
|
|
return value.some((result) => result.blankId === towelId)
|
|
},
|
|
[value]
|
|
)
|
|
|
|
const getTowelText = useCallback(
|
|
(towelId: string) => {
|
|
const result = value.find((item) => item.blankId === towelId)
|
|
return options.find((option) => option.value === result?.optionValue)?.label
|
|
},
|
|
[options, value]
|
|
)
|
|
|
|
// 样式
|
|
const overlayStyle = useMemo<React.CSSProperties>(
|
|
() => ({
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 9999,
|
|
pointerEvents: 'none',
|
|
}),
|
|
[]
|
|
)
|
|
|
|
if (!mounted) return null
|
|
|
|
const content = (
|
|
<AnimatePresence>
|
|
{visible && (
|
|
<motion.div
|
|
key="towel-sales-dialog"
|
|
style={overlayStyle}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
>
|
|
<div
|
|
className={cn('absolute inset-0 flex flex-col', 'bg-black/70')}
|
|
style={{ pointerEvents: 'auto' }}
|
|
>
|
|
{/* 背景蒙版 */}
|
|
{maskSrc && (
|
|
<Image
|
|
src={maskSrc}
|
|
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
|
|
aria-hidden
|
|
/>
|
|
)}
|
|
|
|
{/* 返回按钮 */}
|
|
{onClose && (
|
|
<Image
|
|
src={backSrc}
|
|
onClick={handleClose}
|
|
alt="返回"
|
|
className="absolute top-5 right-5 z-20"
|
|
button
|
|
/>
|
|
)}
|
|
|
|
{/* 内容区域 */}
|
|
<div className="relative z-10 w-full h-full flex flex-col px-30 py-16 pt-30 text-[#432800]">
|
|
<div className="w-full h-full flex flex-col">
|
|
{/* 顶部:题目文本和毛巾区域 */}
|
|
<div className="flex justify-between gap-10 mb-8">
|
|
{/* 题目文本 */}
|
|
<div className="flex-1">
|
|
<div className="text-4xl leading-relaxed">
|
|
【拖拽】从以下话术里,挑选三句组成你的推销语,放置在国货毛巾上。
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右侧:毛巾区域 */}
|
|
<div className="w-180 flex flex-col items-center">
|
|
{/* 毛巾堆叠区域 */}
|
|
<div className="relative w-full flex flex-col">
|
|
{/* 毛巾1 */}
|
|
<div
|
|
data-blank-id="towel1"
|
|
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
|
>
|
|
<img
|
|
src={isTowelLit('towel1') ? towel1Lit : towel1}
|
|
draggable={false}
|
|
alt="毛巾1"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
{getTowelText('towel1') && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
|
{getTowelText('towel1')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 毛巾2 */}
|
|
<div
|
|
data-blank-id="towel2"
|
|
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
|
>
|
|
<img
|
|
src={isTowelLit('towel2') ? towel2Lit : towel2}
|
|
draggable={false}
|
|
alt="毛巾2"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
{getTowelText('towel2') && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
|
{getTowelText('towel2')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 毛巾3 */}
|
|
<div
|
|
data-blank-id="towel3"
|
|
className="relative w-full flex items-center justify-center transition-all duration-300 rounded-2xl"
|
|
>
|
|
<img
|
|
src={isTowelLit('towel3') ? towel3Lit : towel3}
|
|
draggable={false}
|
|
alt="毛巾3"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
{getTowelText('towel3') && (
|
|
<div className="absolute inset-0 flex items-center justify-center text-center text-3xl font-bold leading-snug text-[#432800] pointer-events-none">
|
|
{getTowelText('towel3')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 底部:可选话术区域 - 使用 DragFill */}
|
|
<div className="flex-1">
|
|
<div className="text-5xl font-bold mb-6">答题区</div>
|
|
<DragFill
|
|
options={options}
|
|
blanks={blanks}
|
|
value={value}
|
|
onChange={handleValueChange}
|
|
allowDuplicate={false}
|
|
layout="horizontal-options"
|
|
optionClassName="grid grid-cols-2 gap-10"
|
|
hideLabels={true}
|
|
showSubmitButton={false}
|
|
className="w-full"
|
|
renderOption={(option, isUsed) => {
|
|
// const index = options.findIndex((opt) => opt.value === option.value)
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'px-10 py-10 rounded-2xl border-3 text-3xl font-medium transition-all select-none',
|
|
isUsed
|
|
? 'border-[#8b7355] bg-[#3d3329] text-[#a89378] cursor-not-allowed opacity-50'
|
|
: 'border-[#79471f] bg-black/20 text-white cursor-move hover:border-[#f0be75] hover:shadow-[0_12px_40px_rgba(240,190,117,0.7)]'
|
|
)}
|
|
>
|
|
{option.label}
|
|
</div>
|
|
)
|
|
}}
|
|
renderDragging={(option) => {
|
|
return (
|
|
<div className="w-[638px] h-[90px] flex items-center justify-center rounded-2xl border-3 text-2xl font-medium border-[#f0be75] bg-black/40 text-white shadow-[0_12px_40px_rgba(240,190,117,0.7)]">
|
|
{option.label}
|
|
</div>
|
|
)
|
|
}}
|
|
renderBlank={() => {
|
|
// 返回隐藏元素,实际的空白区域在页面中通过 data-blank-id 定位
|
|
return <div className="hidden" />
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 错误提示 */}
|
|
<ErrorAlert
|
|
text="哎呀!经商要讲诚信,这句话不符合黄先生倡导的商科精神。"
|
|
visible={errorVisible}
|
|
onClose={handleErrorClose}
|
|
onMaskClick={handleErrorClose}
|
|
zIndex={10001}
|
|
/>
|
|
|
|
{/* 成功提示 */}
|
|
<SuccessAlert
|
|
title="恭喜你, 推销语设计成功!"
|
|
content="既突出了国货优势,又传递了爱国情怀,当年的商科学生也是这么做的。"
|
|
visible={successVisible}
|
|
onContinue={handleSuccessContinue}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
|
|
return content
|
|
}
|
|
|
|
export default TowelSalesDialog
|