进度模拟面板写入 puzzleStore 后,App 启动时易班同步会将其覆盖; 现在在同步完成后按 sessionStorage 中保存的模式重放模拟进度, 确保测试包的"到达某故居/全流程完成"调试入口生效。 同时纳入近期未提交的页面、组件、资源与环境变量改动。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { ExhibitFlipOverlay, FlipActionButton, Image, SceneHint } from '@/components'
|
|
import { cn, getBackgroundImage, getPublicPath, getStaticImage } from '@/lib/utils'
|
|
import { filterInput } from '@/lib/utils/sensitiveWords'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import { saveZhanglepingStore, useZhanglepingStore } from '@/store/zhanglepingStore'
|
|
import {
|
|
type BackgroundKey,
|
|
type ClothesKey,
|
|
type MaterialKey,
|
|
type SidebarBlockKey,
|
|
STAR_NAME,
|
|
backgroundToAllowedMaterials,
|
|
buildCompositeDataUrl,
|
|
getBackgroundAssetKey,
|
|
materialConflicts,
|
|
materialOptions,
|
|
sidebarBlocks,
|
|
} from './draw1.config'
|
|
import { TopMenuPanel } from './TopMenuPanel'
|
|
import { SanmaoDialogueItem, SanmaoDialogueSteps } from '@/pages/zhangleping/components'
|
|
|
|
const COMPLETION_DIALOGUE =
|
|
'太好啦!我的记忆都回来啦,还拥有了新时代的模样!爷爷画的我,藏着善良、勇敢与热爱;你画的我,让这些美好品质在今天继续发光!谢谢你,让三毛精神一直传承下去!'
|
|
|
|
const ZhanglepingDraw1 = () => {
|
|
const navigate = useNavigate()
|
|
const { moduleName } = useParams<{ moduleName: string }>()
|
|
const { setDrawArtwork } = useZhanglepingStore()
|
|
|
|
const [activeBlock, setActiveBlock] = useState<SidebarBlockKey>('衣柜')
|
|
const [selectedClothes, setSelectedClothes] = useState<ClothesKey>('默认')
|
|
const [selectedBackground, setSelectedBackground] = useState<BackgroundKey>('城际高铁')
|
|
const [selectedMaterials, setSelectedMaterials] = useState<Set<MaterialKey>>(new Set())
|
|
const [showCaptionInput, setShowCaptionInput] = useState(false)
|
|
const [captionText, setCaptionText] = useState('')
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [createdArtworkSrc, setCreatedArtworkSrc] = useState('')
|
|
const [showCreatedOverlay, setShowCreatedOverlay] = useState(false)
|
|
const [showCompletionDialogue, setShowCompletionDialogue] = useState(false)
|
|
const [submitErrorHintKey, setSubmitErrorHintKey] = useState(0)
|
|
|
|
const captionInputRef = useRef<HTMLInputElement>(null)
|
|
const compositionRef = useRef<HTMLDivElement>(null)
|
|
|
|
const allowedMaterialKeys = useMemo(
|
|
() => backgroundToAllowedMaterials[selectedBackground] ?? [],
|
|
[selectedBackground]
|
|
)
|
|
const backgroundAssetKey = getBackgroundAssetKey(selectedBackground)
|
|
|
|
const scenePreviewSrc = `${STAR_NAME}/reflection/draw/布景/${backgroundAssetKey}`
|
|
const clothingSrc = `${STAR_NAME}/reflection/draw/衣柜/${selectedClothes}`
|
|
const boardBgSrc = `${STAR_NAME}/reflection/draw/画板底图`
|
|
|
|
// 切换布景后:移除不允许的素材
|
|
useEffect(() => {
|
|
setSelectedMaterials((prev) => {
|
|
const next = new Set([...prev].filter((k) => allowedMaterialKeys.includes(k)))
|
|
return next.size === prev.size ? prev : next
|
|
})
|
|
}, [allowedMaterialKeys])
|
|
|
|
useEffect(() => {
|
|
if (showCaptionInput) captionInputRef.current?.focus()
|
|
}, [showCaptionInput])
|
|
|
|
const handleToggleMaterial = (key: MaterialKey) => {
|
|
setSelectedMaterials((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
return next
|
|
}
|
|
const conflicts = materialConflicts[key] ?? []
|
|
for (const c of conflicts) next.delete(c)
|
|
if (next.size < 3) next.add(key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleSubmitArtwork = async () => {
|
|
if (!showCaptionInput) {
|
|
setShowCaptionInput(true)
|
|
return
|
|
}
|
|
if (isSubmitting || !compositionRef.current) return
|
|
|
|
const filteredCaptionText = filterInput(captionText.trim())
|
|
if (Array.from(filteredCaptionText).length > 15) {
|
|
setCaptionText(Array.from(filteredCaptionText).slice(0, 15).join(''))
|
|
setSubmitErrorHintKey((key) => key + 1)
|
|
return
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
const dataUrl = await buildCompositeDataUrl(compositionRef.current)
|
|
setDrawArtwork(dataUrl, filteredCaptionText)
|
|
saveZhanglepingStore()
|
|
setCreatedArtworkSrc(dataUrl)
|
|
setShowCreatedOverlay(true)
|
|
} catch (error) {
|
|
console.error('Failed to submit draw artwork', error)
|
|
setSubmitErrorHintKey((key) => key + 1)
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleCollectArtwork = () => {
|
|
navigate(`/${moduleName}/${STAR_NAME}/reflection5`)
|
|
}
|
|
|
|
const isPreviewVisible = activeBlock !== '任务提示'
|
|
|
|
if (!moduleName) return null
|
|
|
|
return (
|
|
<div
|
|
className="w-full h-full relative overflow-hidden bg-cover bg-center bg-no-repeat"
|
|
style={{ backgroundImage: getBackgroundImage(boardBgSrc) }}
|
|
>
|
|
{/* 中央预览区 */}
|
|
<div
|
|
ref={compositionRef}
|
|
className="absolute left-20 bottom-[86px] w-[1605px] h-[904px] bg-cover bg-center bg-no-repeat"
|
|
style={{ backgroundImage: getBackgroundImage(scenePreviewSrc) }}
|
|
>
|
|
<div className="relative w-full h-full">
|
|
{isPreviewVisible && (
|
|
<div className="absolute left-111 bottom-0 z-10">
|
|
<Image src={getStaticImage(clothingSrc)} alt="衣柜选项" />
|
|
</div>
|
|
)}
|
|
|
|
{isPreviewVisible &&
|
|
materialOptions
|
|
.filter((opt) => selectedMaterials.has(opt.key))
|
|
.map((opt) => (
|
|
<div
|
|
key={opt.key}
|
|
className={cn('absolute', opt.key === '车' ? 'z-8' : 'z-20')}
|
|
data-material-key={opt.key}
|
|
style={{
|
|
top: opt.position.top,
|
|
left: opt.position.left,
|
|
bottom: opt.position.bottom,
|
|
right: opt.position.right,
|
|
}}
|
|
>
|
|
<Image src={getStaticImage(opt.assetSrc)} alt={opt.key} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 顶部菜单 */}
|
|
<div className="absolute left-0 top-4 z-30 w-full">
|
|
<TopMenuPanel
|
|
activeBlock={activeBlock}
|
|
selectedClothes={selectedClothes}
|
|
selectedBackground={selectedBackground}
|
|
selectedMaterials={selectedMaterials}
|
|
allowedMaterialKeys={allowedMaterialKeys}
|
|
onSelectClothes={setSelectedClothes}
|
|
onSelectBackground={setSelectedBackground}
|
|
onToggleMaterial={handleToggleMaterial}
|
|
/>
|
|
</div>
|
|
|
|
{/* 任务提示窗口 */}
|
|
{activeBlock === '任务提示' && (
|
|
<div
|
|
className="absolute left-8 top-[26%] z-30 w-[1724px] h-[424px] bg-no-repeat"
|
|
style={{
|
|
backgroundImage: getBackgroundImage(`${STAR_NAME}/reflection/draw/创作任务面板`),
|
|
backgroundSize: '100% 100%',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-center pt-28">
|
|
<div className="w-full text-center text-[#8B6914] font-medium leading-tight">
|
|
<div className="text-3xl space-y-8">
|
|
<div>①为三毛换装</div>
|
|
<div>②搭配1个新时代场景</div>
|
|
<div>③融入1-3个新时代元素</div>
|
|
<div>④配1句简短正能量文字(不超过15个字)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 右侧侧边栏 */}
|
|
<div className="absolute right-[4px] top-[162px] z-40 w-[162px]">
|
|
<div className="flex flex-col gap-4">
|
|
{sidebarBlocks.map((blk) => (
|
|
<button key={blk.key} type="button" onClick={() => setActiveBlock(blk.key)}>
|
|
<Image
|
|
className={cn(activeBlock === blk.key ? 'opacity-100' : 'opacity-60')}
|
|
src={getStaticImage(blk.iconSrc)}
|
|
alt={blk.key}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 底部输入框 */}
|
|
{showCaptionInput && (
|
|
<div
|
|
className="absolute left-20 bottom-2.5 bg-cover bg-center bg-no-repeat w-[1724px] h-[100px] z-40"
|
|
style={{ backgroundImage: getBackgroundImage(`${STAR_NAME}/reflection/draw/写字底板`) }}
|
|
>
|
|
<div className="flex items-center justify-center">
|
|
<input
|
|
ref={captionInputRef}
|
|
type="text"
|
|
id="textInput"
|
|
name="textInput"
|
|
maxLength={15}
|
|
value={captionText}
|
|
onChange={(e) =>
|
|
setCaptionText(Array.from(filterInput(e.target.value)).slice(0, 15).join(''))
|
|
}
|
|
className={cn(
|
|
'w-260 h-24 bg-transparent',
|
|
'text-4xl! text-[#8B6914] leading-none placeholder:text-3xl!'
|
|
)}
|
|
autoComplete="off"
|
|
placeholder="提交之前,请给你创作的我配一句简短的正能量文字吧!(不超过15个字)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 底部按钮 */}
|
|
<div className="absolute -left-10 bottom-0 z-50">
|
|
<Image
|
|
src={getStaticImage(`${STAR_NAME}/reflection/draw/返回`)}
|
|
button
|
|
onClick={() => navigate(`/${moduleName}/${STAR_NAME}/draw`)}
|
|
/>
|
|
</div>
|
|
<div className="absolute -right-10 bottom-0 z-50">
|
|
<Image
|
|
src={getStaticImage(`${STAR_NAME}/reflection/draw/提交作品`)}
|
|
button
|
|
className={isSubmitting ? 'opacity-70 pointer-events-none' : undefined}
|
|
onClick={handleSubmitArtwork}
|
|
/>
|
|
</div>
|
|
|
|
<SceneHint when={submitErrorHintKey > 0} triggerKey={submitErrorHintKey} durationMs={3200}>
|
|
作品生成失败,请稍后再试
|
|
</SceneHint>
|
|
|
|
{createdArtworkSrc && (
|
|
<ExhibitFlipOverlay
|
|
src={createdArtworkSrc}
|
|
visible={showCreatedOverlay}
|
|
// footerClassName="bottom-120"
|
|
onEnterComplete={() => {
|
|
setShowCompletionDialogue(true)
|
|
}}
|
|
onClose={() => {
|
|
setShowCreatedOverlay(false)
|
|
}}
|
|
footer={<FlipActionButton onClick={handleCollectArtwork}>收藏</FlipActionButton>}
|
|
/>
|
|
)}
|
|
|
|
<SanmaoDialogueSteps
|
|
visible={showCompletionDialogue}
|
|
onVisibleChange={setShowCompletionDialogue}
|
|
showMask={false}
|
|
audioChannelId="zhangleping-draw-completion"
|
|
zIndex={10000}
|
|
>
|
|
<SanmaoDialogueItem audioSrc={getPublicPath('audio/zhangleping/mp3_zhangleping_30_1.mp3')}>
|
|
{COMPLETION_DIALOGUE}
|
|
</SanmaoDialogueItem>
|
|
</SanmaoDialogueSteps>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ZhanglepingDraw1
|