fix: 优化进度调试与答题交互
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d445f81e60
commit
6491b0c918
6 files changed
+378
-151
No files matched your search
@@ -67,6 +67,11 @@ function AppContent() {
|
|||||||
console.error('Failed to load puzzle store', e)
|
console.error('Failed to load puzzle store', e)
|
||||||
} finally {
|
} finally {
|
||||||
await applyStoredProgressionDebugMode()
|
await applyStoredProgressionDebugMode()
|
||||||
|
// 仅在测试包启用时应用阶段覆盖(防止 sessionStorage 残留污染生产数据)
|
||||||
|
if (import.meta.env.VITE_DEBUG_ENTRY === 'true') {
|
||||||
|
const { applyStoredStageOverrides } = await import('@/debug/progressionDebug')
|
||||||
|
applyStoredStageOverrides()
|
||||||
|
}
|
||||||
usePuzzleStore.getState().setLoaded(true)
|
usePuzzleStore.getState().setLoaded(true)
|
||||||
setBootstrapDone(true)
|
setBootstrapDone(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Image, PuzzleSystem, flyToAnimation } from '@/components'
|
|||||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
import { getPuzzlePieceData } from '@/config/puzzleData'
|
import { getPuzzlePieceData } from '@/config/puzzleData'
|
||||||
import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
|
import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal'
|
||||||
|
import { setPreviousPath } from '@/hooks/useResidenceContinue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 支持拼图盒功能的故居列表
|
* 支持拼图盒功能的故居列表
|
||||||
@@ -349,7 +350,8 @@ const ResidenceLayoutContent = () => {
|
|||||||
setIsVideoFadingOut(false)
|
setIsVideoFadingOut(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新前一个路径
|
// 更新前一个路径(供 useResidenceContinue 判断是否从场景回来)
|
||||||
|
setPreviousPath(previousPathRef.current)
|
||||||
previousPathRef.current = location.pathname
|
previousPathRef.current = location.pathname
|
||||||
}, [location.pathname, videoSrc, isForwardNavigation])
|
}, [location.pathname, videoSrc, isForwardNavigation])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { forwardRef, useEffect, useState } from 'react'
|
import { forwardRef, useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import ScaledPortal from '@/components/layout/ScaledPortal'
|
||||||
import FlipActionButton from '@/components/button/FlipActionButton'
|
import FlipActionButton from '@/components/button/FlipActionButton'
|
||||||
import PuzzlePieceDisplay from './PuzzlePieceDisplay'
|
import PuzzlePieceDisplay from './PuzzlePieceDisplay'
|
||||||
|
|
||||||
@@ -38,144 +38,144 @@ const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [pieceId])
|
}, [pieceId])
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<ScaledPortal zIndex={100001}>
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
width: 'min(520px, 86vw)',
|
|
||||||
height: 'min(420px, 72vh)',
|
|
||||||
transform: 'translate(-50%, -50%) translateZ(0)',
|
|
||||||
perspective: '1000px',
|
|
||||||
zIndex: 100001,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
willChange: 'transform, opacity',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{awaitingCollect && !hidden && !confirming && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-1/2 top-0 -translate-x-1/2 -z-10"
|
|
||||||
style={{
|
|
||||||
width: 'min(440px, 76vw)',
|
|
||||||
height: '320px',
|
|
||||||
background:
|
|
||||||
'radial-gradient(ellipse 58% 46% at 50% 38%, rgba(255,214,133,0.16) 0%, rgba(255,185,94,0.07) 45%, transparent 76%)',
|
|
||||||
willChange: 'opacity, transform',
|
|
||||||
}}
|
|
||||||
initial={{ opacity: 0, scale: 0.94 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute left-1/2 top-0 -translate-x-1/2"
|
|
||||||
style={{
|
style={{
|
||||||
width: 'clamp(240px, 24vw, 300px)',
|
position: 'absolute',
|
||||||
height: 'clamp(190px, 20vw, 240px)',
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: 'min(520px, 86vw)',
|
||||||
|
height: 'min(420px, 72vh)',
|
||||||
|
transform: 'translate(-50%, -50%) translateZ(0)',
|
||||||
|
perspective: '1000px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<motion.div
|
{awaitingCollect && !hidden && !confirming && (
|
||||||
ref={ref}
|
<motion.div
|
||||||
|
className="absolute left-1/2 top-0 -translate-x-1/2 -z-10"
|
||||||
|
style={{
|
||||||
|
width: 'min(440px, 76vw)',
|
||||||
|
height: '320px',
|
||||||
|
background:
|
||||||
|
'radial-gradient(ellipse 58% 46% at 50% 38%, rgba(255,214,133,0.16) 0%, rgba(255,185,94,0.07) 45%, transparent 76%)',
|
||||||
|
willChange: 'opacity, transform',
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, scale: 0.94 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-0 -translate-x-1/2"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: 'clamp(240px, 24vw, 300px)',
|
||||||
height: '100%',
|
height: 'clamp(190px, 20vw, 240px)',
|
||||||
transformStyle: 'preserve-3d',
|
|
||||||
backfaceVisibility: 'hidden',
|
|
||||||
WebkitBackfaceVisibility: 'hidden',
|
|
||||||
willChange: 'transform, opacity',
|
|
||||||
}}
|
}}
|
||||||
initial={
|
>
|
||||||
isAnimating
|
<motion.div
|
||||||
? {
|
ref={ref}
|
||||||
scale: 0,
|
style={{
|
||||||
rotateY: -180,
|
width: '100%',
|
||||||
opacity: 0,
|
height: '100%',
|
||||||
y: 0,
|
transformStyle: 'preserve-3d',
|
||||||
}
|
backfaceVisibility: 'hidden',
|
||||||
: false
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
}
|
willChange: 'transform, opacity',
|
||||||
animate={
|
}}
|
||||||
hidden
|
initial={
|
||||||
? { opacity: 0, visibility: 'hidden' as const }
|
isAnimating
|
||||||
: confirming
|
|
||||||
? {
|
? {
|
||||||
scale: 1.035,
|
scale: 0,
|
||||||
rotateY: 0,
|
rotateY: -180,
|
||||||
opacity: 1,
|
opacity: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
visibility: 'visible' as const,
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
hidden
|
||||||
|
? { opacity: 0, visibility: 'hidden' as const }
|
||||||
|
: confirming
|
||||||
|
? {
|
||||||
|
scale: 1.035,
|
||||||
|
rotateY: 0,
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
visibility: 'visible' as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scale: 1,
|
||||||
|
rotateY: 0,
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
visibility: 'visible' as const,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
confirming
|
||||||
|
? {
|
||||||
|
duration: 0.18,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
scale: 1,
|
|
||||||
rotateY: 0,
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
visibility: 'visible' as const,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={
|
|
||||||
confirming
|
|
||||||
? {
|
|
||||||
duration: 0.18,
|
|
||||||
ease: [0.22, 1, 0.36, 1],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
duration: 0.82,
|
|
||||||
ease: [0.2, 0.72, 0.18, 1],
|
|
||||||
scale: {
|
|
||||||
duration: 0.82,
|
duration: 0.82,
|
||||||
ease: [0.2, 0.72, 0.18, 1],
|
ease: [0.2, 0.72, 0.18, 1],
|
||||||
},
|
scale: {
|
||||||
rotateY: {
|
duration: 0.82,
|
||||||
duration: 0.82,
|
ease: [0.2, 0.72, 0.18, 1],
|
||||||
ease: [0.25, 0.46, 0.45, 0.94],
|
},
|
||||||
},
|
rotateY: {
|
||||||
opacity: {
|
duration: 0.82,
|
||||||
duration: 0.55,
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
ease: 'easeOut',
|
},
|
||||||
},
|
opacity: {
|
||||||
}
|
duration: 0.55,
|
||||||
}
|
ease: 'easeOut',
|
||||||
>
|
},
|
||||||
<PuzzlePieceDisplay
|
}
|
||||||
residence={residence}
|
}
|
||||||
pieceId={pieceId}
|
|
||||||
showGlow={isAnimating || awaitingCollect}
|
|
||||||
fitContain={true}
|
|
||||||
className="w-full h-full"
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{awaitingCollect && !hidden && (
|
|
||||||
<motion.div
|
|
||||||
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
|
|
||||||
style={{ willChange: 'transform, opacity' }}
|
|
||||||
>
|
|
||||||
{puzzlePieceTitle && (
|
|
||||||
<div className="max-w-[480px] rounded-2xl border border-[#f6d58b]/45 bg-[#2a1608]/70 px-6 py-2.5 text-center text-2xl font-bold leading-snug text-[#fff4d0] shadow-[0_6px_20px_rgba(0,0,0,0.22)] [text-shadow:0_2px_0_#5a2a00,0_0_14px_rgba(255,224,150,0.75)]">
|
|
||||||
{puzzlePieceTitle.split('\n').map((line) => (
|
|
||||||
<div key={line}>{line}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FlipActionButton
|
|
||||||
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
|
|
||||||
disabled={confirming}
|
|
||||||
onClick={onCollect}
|
|
||||||
>
|
>
|
||||||
收下
|
<PuzzlePieceDisplay
|
||||||
</FlipActionButton>
|
residence={residence}
|
||||||
</motion.div>
|
pieceId={pieceId}
|
||||||
)}
|
showGlow={isAnimating || awaitingCollect}
|
||||||
</div>,
|
fitContain={true}
|
||||||
document.body
|
className="w-full h-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{awaitingCollect && !hidden && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute left-0 right-0 top-[clamp(218px,25vw,276px)] flex flex-col items-center gap-3 pointer-events-none"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={confirming ? { opacity: 0, y: 6 } : { opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: confirming ? 0.16 : 0.36, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
style={{ willChange: 'transform, opacity' }}
|
||||||
|
>
|
||||||
|
{puzzlePieceTitle && (
|
||||||
|
<div className="max-w-[480px] rounded-2xl border border-[#f6d58b]/45 bg-[#2a1608]/70 px-6 py-2.5 text-center text-2xl font-bold leading-snug text-[#fff4d0] shadow-[0_6px_20px_rgba(0,0,0,0.22)] [text-shadow:0_2px_0_#5a2a00,0_0_14px_rgba(255,224,150,0.75)]">
|
||||||
|
{puzzlePieceTitle.split('\n').map((line) => (
|
||||||
|
<div key={line}>{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FlipActionButton
|
||||||
|
className="pointer-events-auto border border-[#ffe6a6]/55 bg-linear-to-b from-[#bd8b50] to-[#84512b] px-4 py-1.5 text-base shadow-[0_3px_10px_rgba(0,0,0,0.2)] hover:brightness-105 active:scale-[0.97]"
|
||||||
|
disabled={confirming}
|
||||||
|
onClick={onCollect}
|
||||||
|
>
|
||||||
|
收下
|
||||||
|
</FlipActionButton>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScaledPortal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -296,7 +296,11 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
|||||||
for (const anchor of anchors) {
|
for (const anchor of anchors) {
|
||||||
if (!isAnchorTriggerable(anchor)) continue
|
if (!isAnchorTriggerable(anchor)) continue
|
||||||
const timeDiff = time - anchor.time
|
const timeDiff = time - anchor.time
|
||||||
if (timeDiff >= 0 && timeDiff < 0.5) {
|
if (timeDiff >= 0) {
|
||||||
|
// 时间跳变过大(如退后台返回)时回拨到锚点位置,保证题在准确时刻弹出
|
||||||
|
if (timeDiff > 1.5) {
|
||||||
|
video.currentTime = anchor.time
|
||||||
|
}
|
||||||
triggerAnchor(anchor, video)
|
triggerAnchor(anchor, video)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -310,7 +314,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
|||||||
for (const anchor of anchors) {
|
for (const anchor of anchors) {
|
||||||
if (!isAnchorTriggerable(anchor)) continue
|
if (!isAnchorTriggerable(anchor)) continue
|
||||||
const timeDiff = targetTime - anchor.time
|
const timeDiff = targetTime - anchor.time
|
||||||
if (timeDiff >= 0 && timeDiff < 0.5) {
|
if (timeDiff >= 0) {
|
||||||
triggerAnchor(anchor, videoRef.current)
|
triggerAnchor(anchor, videoRef.current)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -577,8 +581,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
|
|||||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
// 视频首次观看限制:env 默认值可被调试面板运行时覆盖
|
// 视频首次观看限制:env 默认值可被调试面板运行时覆盖
|
||||||
const envDisableGating =
|
const envDisableGating = import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
||||||
import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
|
||||||
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
|
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
|
||||||
const disableFirstWatchVideoGating =
|
const disableFirstWatchVideoGating =
|
||||||
sessionOverride !== null ? sessionOverride === 'disabled' : envDisableGating
|
sessionOverride !== null ? sessionOverride === 'disabled' : envDisableGating
|
||||||
|
|||||||
+229
-16
@@ -1,10 +1,10 @@
|
|||||||
import { puzzleDataConfig } from '@/config/puzzleData'
|
import { puzzleDataConfig } from '@/config/puzzleData'
|
||||||
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||||||
import { usePuzzleStore } from '@/store/puzzleStore'
|
import { usePuzzleStore } from '@/store/puzzleStore'
|
||||||
import { savePuzzleStore } from '@/store/puzzleStore'
|
|
||||||
|
|
||||||
const MODE_KEY = 'debug:progression-mode'
|
const MODE_KEY = 'debug:progression-mode'
|
||||||
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
|
const BACKUP_KEY = 'debug:progression-real-puzzles-backup'
|
||||||
|
const STAGE_OVERRIDES_KEY = 'debug:stage-overrides'
|
||||||
const POSITION_KEY = 'debug:progression-badge-position'
|
const POSITION_KEY = 'debug:progression-badge-position'
|
||||||
const BADGE_ID = 'progression-debug-badge'
|
const BADGE_ID = 'progression-debug-badge'
|
||||||
const PANEL_ID = 'progression-debug-panel'
|
const PANEL_ID = 'progression-debug-panel'
|
||||||
@@ -104,7 +104,10 @@ const getStagePieceIds = (residence: string) => {
|
|||||||
/**
|
/**
|
||||||
* 获取某个故居在某阶段已收集的碎片数 / 总数
|
* 获取某个故居在某阶段已收集的碎片数 / 总数
|
||||||
*/
|
*/
|
||||||
const getStageProgress = (residence: string, stageKey: StageKey): { collected: number; total: number } => {
|
const getStageProgress = (
|
||||||
|
residence: string,
|
||||||
|
stageKey: StageKey
|
||||||
|
): { collected: number; total: number } => {
|
||||||
const stagePieceIds = getStagePieceIds(residence)[stageKey]
|
const stagePieceIds = getStagePieceIds(residence)[stageKey]
|
||||||
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
|
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
|
||||||
const collected = stagePieceIds.filter((id) => currentPieces.has(id)).length
|
const collected = stagePieceIds.filter((id) => currentPieces.has(id)).length
|
||||||
@@ -136,31 +139,238 @@ const buildPuzzlesWithAncestors = (residence: string): Record<string, Set<number
|
|||||||
return puzzles
|
return puzzles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 级联清空后序故居碎片:如果某故居碎片不完整,
|
||||||
|
* 后续所有故居都不可解锁,需要清空其碎片。
|
||||||
|
*/
|
||||||
|
const cascadeClearSuccessors = (puzzles: Record<string, Set<number>>, residence: string) => {
|
||||||
|
const allPieceIds = getAllPieceIds(residence)
|
||||||
|
const current = puzzles[residence]
|
||||||
|
const isComplete = allPieceIds.length > 0 && allPieceIds.every((id) => current?.has(id))
|
||||||
|
|
||||||
|
if (isComplete) return
|
||||||
|
|
||||||
|
const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
|
||||||
|
// 防守:residence 不在列表中时 findIndex 返回 -1,不清空全部
|
||||||
|
if (targetIndex < 0) return
|
||||||
|
for (let i = targetIndex + 1; i < PUZZLE_RESIDENCES.length; i++) {
|
||||||
|
const next = PUZZLE_RESIDENCES[i]?.residence
|
||||||
|
if (!next) continue
|
||||||
|
delete puzzles[next]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 级联清理 sessionStorage 中的阶段覆盖:清空后序故居的覆盖记录
|
||||||
|
* puzzles 参数传入的是正在构建的新 puzzles 对象(非 store 中的旧数据)
|
||||||
|
*/
|
||||||
|
const cascadeClearOverrides = (
|
||||||
|
overrides: StageOverrides,
|
||||||
|
residence: string,
|
||||||
|
puzzles: Record<string, Set<number>>
|
||||||
|
) => {
|
||||||
|
const allPieceIds = getAllPieceIds(residence)
|
||||||
|
const current = puzzles[residence]
|
||||||
|
const isComplete = allPieceIds.length > 0 && allPieceIds.every((id) => current?.has(id))
|
||||||
|
|
||||||
|
if (isComplete) return
|
||||||
|
|
||||||
|
const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
|
||||||
|
if (targetIndex < 0) return
|
||||||
|
for (let i = targetIndex + 1; i < PUZZLE_RESIDENCES.length; i++) {
|
||||||
|
const next = PUZZLE_RESIDENCES[i]?.residence
|
||||||
|
if (!next) continue
|
||||||
|
delete overrides[next]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶段覆盖状态:true=补全,false=移除 */
|
||||||
|
type StageOverrides = Record<string, Partial<Record<StageKey, boolean>>>
|
||||||
|
|
||||||
|
/** 读取当前 sessionStorage 中的阶段覆盖 */
|
||||||
|
const getStoredStageOverrides = (): StageOverrides => {
|
||||||
|
const raw = window.sessionStorage.getItem(STAGE_OVERRIDES_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as StageOverrides
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入阶段覆盖到 sessionStorage */
|
||||||
|
const setStoredStageOverrides = (overrides: StageOverrides) => {
|
||||||
|
if (Object.keys(overrides).length === 0) {
|
||||||
|
window.sessionStorage.removeItem(STAGE_OVERRIDES_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(STAGE_OVERRIDES_KEY, JSON.stringify(overrides))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直接切换一个故居某个阶段的碎片收集状态(无确认、无刷新)
|
* 直接切换一个故居某个阶段的碎片收集状态(无确认、无刷新)
|
||||||
|
* 只写 sessionStorage 覆盖标记 + puzzleStore,不调用易班保存。
|
||||||
*/
|
*/
|
||||||
const toggleResidenceStage = (residence: string, stageKey: StageKey) => {
|
const toggleResidenceStage = (residence: string, stageKey: StageKey) => {
|
||||||
const stagePieceIds = getStagePieceIds(residence)[stageKey]
|
const stagePieceIds = getStagePieceIds(residence)[stageKey]
|
||||||
if (stagePieceIds.length === 0) return
|
if (stagePieceIds.length === 0) return
|
||||||
|
|
||||||
|
const allStageIds = getStagePieceIds(residence)
|
||||||
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
|
const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set<number>()
|
||||||
const allCollected = stagePieceIds.every((id) => currentPieces.has(id))
|
const allCollected = stagePieceIds.every((id) => currentPieces.has(id))
|
||||||
const puzzles = buildPuzzlesWithAncestors(residence)
|
const puzzles = buildPuzzlesWithAncestors(residence)
|
||||||
|
|
||||||
|
const overrides = getStoredStageOverrides()
|
||||||
|
|
||||||
if (allCollected) {
|
if (allCollected) {
|
||||||
|
// 移除:收集中需要去掉的阶段碎片(含被级联影响的后续阶段)
|
||||||
|
const removalIds = new Set(stagePieceIds)
|
||||||
|
if (stageKey === 'dialogue') {
|
||||||
|
for (const id of allStageIds.reflection) removalIds.add(id)
|
||||||
|
for (const id of allStageIds.internalization) removalIds.add(id)
|
||||||
|
} else if (stageKey === 'reflection') {
|
||||||
|
for (const id of allStageIds.internalization) removalIds.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
const remaining = new Set<number>()
|
const remaining = new Set<number>()
|
||||||
for (const id of currentPieces) {
|
for (const id of currentPieces) {
|
||||||
if (!stagePieceIds.includes(id)) {
|
if (!removalIds.has(id)) {
|
||||||
remaining.add(id)
|
remaining.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
puzzles[residence] = remaining
|
puzzles[residence] = remaining
|
||||||
|
|
||||||
|
// 级联清理 sessionStorage 覆盖
|
||||||
|
if (stageKey === 'dialogue') {
|
||||||
|
overrides[residence] = {
|
||||||
|
...overrides[residence],
|
||||||
|
dialogue: false,
|
||||||
|
reflection: false,
|
||||||
|
internalization: false,
|
||||||
|
}
|
||||||
|
} else if (stageKey === 'reflection') {
|
||||||
|
overrides[residence] = { ...overrides[residence], reflection: false, internalization: false }
|
||||||
|
} else {
|
||||||
|
overrides[residence] = { ...overrides[residence], [stageKey]: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宅间级联
|
||||||
|
cascadeClearSuccessors(puzzles, residence)
|
||||||
|
cascadeClearOverrides(overrides, residence, puzzles)
|
||||||
} else {
|
} else {
|
||||||
puzzles[residence] = new Set([...currentPieces, ...stagePieceIds])
|
// 补全:自动补齐前序阶段(点击内化 → 对话+体悟+内化一起补全)
|
||||||
|
const fillIds = new Set(stagePieceIds)
|
||||||
|
if (stageKey === 'reflection') {
|
||||||
|
for (const id of allStageIds.dialogue) fillIds.add(id)
|
||||||
|
} else if (stageKey === 'internalization') {
|
||||||
|
for (const id of allStageIds.dialogue) fillIds.add(id)
|
||||||
|
for (const id of allStageIds.reflection) fillIds.add(id)
|
||||||
|
}
|
||||||
|
puzzles[residence] = new Set([...currentPieces, ...fillIds])
|
||||||
|
|
||||||
|
if (!overrides[residence]) overrides[residence] = {}
|
||||||
|
if (stageKey === 'reflection') {
|
||||||
|
overrides[residence] = { ...overrides[residence], dialogue: true, reflection: true }
|
||||||
|
} else if (stageKey === 'internalization') {
|
||||||
|
overrides[residence] = {
|
||||||
|
...overrides[residence],
|
||||||
|
dialogue: true,
|
||||||
|
reflection: true,
|
||||||
|
internalization: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
overrides[residence][stageKey] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理无意义的覆盖记录(覆盖值等于已有状态)
|
||||||
|
// 序列化下一轮刷新时会自动被 applyStoredStageOverrides 处理
|
||||||
|
|
||||||
|
setStoredStageOverrides(overrides)
|
||||||
|
usePuzzleStore.setState({ puzzles })
|
||||||
|
// 注意:不调 savePuzzleStore —— 阶段模拟不应污染易班数据
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 App bootstrap 时重新应用阶段覆盖(刷新后恢复模拟进度)
|
||||||
|
* 应在 loadPuzzleStore(易班/真实数据加载)之后调用。
|
||||||
|
* 按解锁链顺序应用,确保级联一致性。
|
||||||
|
*/
|
||||||
|
export const applyStoredStageOverrides = () => {
|
||||||
|
const overrides = getStoredStageOverrides()
|
||||||
|
if (Object.keys(overrides).length === 0) return
|
||||||
|
|
||||||
|
const puzzles: Record<string, Set<number>> = {
|
||||||
|
...usePuzzleStore.getState().puzzles,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按解锁链顺序遍历,保证前序修改可以级联影响后序
|
||||||
|
for (const { residence } of PUZZLE_RESIDENCES) {
|
||||||
|
const stageOverrides = overrides[residence]
|
||||||
|
if (!stageOverrides) continue
|
||||||
|
|
||||||
|
// 确保前序完整:任一前序不完整 → 当前故居的覆盖无效,跳过
|
||||||
|
const targetIndex = PUZZLE_RESIDENCES.findIndex((item) => item.residence === residence)
|
||||||
|
let predecessorIncomplete = false
|
||||||
|
for (let i = 0; i < targetIndex; i++) {
|
||||||
|
const prev = PUZZLE_RESIDENCES[i]?.residence
|
||||||
|
if (!prev) continue
|
||||||
|
const prevPieces = puzzles[prev]
|
||||||
|
if (!prevPieces || getAllPieceIds(prev).some((id) => !prevPieces.has(id))) {
|
||||||
|
predecessorIncomplete = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (predecessorIncomplete) {
|
||||||
|
delete puzzles[residence]
|
||||||
|
delete overrides[residence]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagePieceIds = getStagePieceIds(residence)
|
||||||
|
const residencePieces = puzzles[residence] ? new Set(puzzles[residence]) : new Set<number>()
|
||||||
|
|
||||||
|
for (const stageKey of Object.keys(stageOverrides) as StageKey[]) {
|
||||||
|
const pieceIds = stagePieceIds[stageKey]
|
||||||
|
if (pieceIds.length === 0) continue
|
||||||
|
|
||||||
|
if (stageOverrides[stageKey]) {
|
||||||
|
// 补全该阶段 + 自动补齐前序阶段(点击内化 → 对话+体悟+内化)
|
||||||
|
const fillIds = new Set(pieceIds)
|
||||||
|
if (stageKey === 'reflection') {
|
||||||
|
for (const id of stagePieceIds.dialogue) fillIds.add(id)
|
||||||
|
} else if (stageKey === 'internalization') {
|
||||||
|
for (const id of stagePieceIds.dialogue) fillIds.add(id)
|
||||||
|
for (const id of stagePieceIds.reflection) fillIds.add(id)
|
||||||
|
}
|
||||||
|
for (const id of fillIds) residencePieces.add(id)
|
||||||
|
} else {
|
||||||
|
// 移除该阶段 + 宅内级联:移除依赖该阶段的后序阶段
|
||||||
|
const removalIds = new Set(pieceIds)
|
||||||
|
if (stageKey === 'dialogue') {
|
||||||
|
for (const id of stagePieceIds.reflection) removalIds.add(id)
|
||||||
|
for (const id of stagePieceIds.internalization) removalIds.add(id)
|
||||||
|
} else if (stageKey === 'reflection') {
|
||||||
|
for (const id of stagePieceIds.internalization) removalIds.add(id)
|
||||||
|
}
|
||||||
|
for (const id of removalIds) residencePieces.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (residencePieces.size > 0) {
|
||||||
|
puzzles[residence] = residencePieces
|
||||||
|
} else {
|
||||||
|
delete puzzles[residence]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 级联:如果当前故居不完整,清空后续所有
|
||||||
|
cascadeClearSuccessors(puzzles, residence)
|
||||||
|
cascadeClearOverrides(overrides, residence, puzzles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理无效覆盖后回写
|
||||||
|
setStoredStageOverrides(overrides)
|
||||||
usePuzzleStore.setState({ puzzles })
|
usePuzzleStore.setState({ puzzles })
|
||||||
savePuzzleStore(puzzles)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStoredMode = (): DebugMode => {
|
const getStoredMode = (): DebugMode => {
|
||||||
@@ -334,11 +544,19 @@ const removePanel = () => {
|
|||||||
const renderStageButton = (
|
const renderStageButton = (
|
||||||
residence: string,
|
residence: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
stage: StageConfig,
|
stage: StageConfig
|
||||||
): HTMLButtonElement => {
|
): HTMLButtonElement => {
|
||||||
const { collected, total } = getStageProgress(residence, stage.key)
|
const { collected, total } = getStageProgress(residence, stage.key)
|
||||||
const isFull = total > 0 && collected >= total
|
const isFull = total > 0 && collected >= total
|
||||||
|
|
||||||
|
// 点击时自动补齐前序阶段(体悟→对话+体悟,内化→对话+体悟+内化)
|
||||||
|
const autoFillHint =
|
||||||
|
stage.key === 'reflection' && total > 0
|
||||||
|
? '(自动补齐对话)'
|
||||||
|
: stage.key === 'internalization' && total > 0
|
||||||
|
? '(自动补齐对话+体悟)'
|
||||||
|
: ''
|
||||||
|
|
||||||
const btn = document.createElement('button')
|
const btn = document.createElement('button')
|
||||||
btn.type = 'button'
|
btn.type = 'button'
|
||||||
btn.textContent = `${stage.label} ${collected}/${total}`
|
btn.textContent = `${stage.label} ${collected}/${total}`
|
||||||
@@ -360,24 +578,23 @@ const renderStageButton = (
|
|||||||
btn.style.borderColor = 'rgba(100,220,120,0.5)'
|
btn.style.borderColor = 'rgba(100,220,120,0.5)'
|
||||||
btn.style.background = 'rgba(80,200,100,0.18)'
|
btn.style.background = 'rgba(80,200,100,0.18)'
|
||||||
btn.style.color = '#99e8aa'
|
btn.style.color = '#99e8aa'
|
||||||
btn.title = `「${stage.label}」已完成(${total} 块)。点击移除。`
|
btn.title = `「${stage.label}」已完成(${total} 块)。点击移除(级联移除后序阶段)。`
|
||||||
} else if (collected > 0) {
|
} else if (collected > 0) {
|
||||||
btn.style.borderColor = 'rgba(255,200,80,0.5)'
|
btn.style.borderColor = 'rgba(255,200,80,0.5)'
|
||||||
btn.style.background = 'rgba(255,180,60,0.15)'
|
btn.style.background = 'rgba(255,180,60,0.15)'
|
||||||
btn.style.color = '#ffd080'
|
btn.style.color = '#ffd080'
|
||||||
btn.title = `「${stage.label}」部分完成(${collected}/${total})。点击补全。`
|
btn.title = `「${stage.label}」部分完成(${collected}/${total})。点击补全${autoFillHint}。`
|
||||||
} else {
|
} else {
|
||||||
btn.style.borderColor = 'rgba(100,180,255,0.4)'
|
btn.style.borderColor = 'rgba(100,180,255,0.4)'
|
||||||
btn.style.background = 'rgba(80,150,255,0.12)'
|
btn.style.background = 'rgba(80,150,255,0.12)'
|
||||||
btn.style.color = '#99ccff'
|
btn.style.color = '#99ccff'
|
||||||
btn.title = `「${stage.label}」未开始。点击补全(${total} 块)。`
|
btn.title = `「${stage.label}」未开始。点击补全${autoFillHint}(${total} 块)。`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
toggleResidenceStage(residence, stage.key)
|
toggleResidenceStage(residence, stage.key)
|
||||||
// 即时刷新面板按钮状态
|
|
||||||
removePanel()
|
removePanel()
|
||||||
togglePanel()
|
togglePanel()
|
||||||
})
|
})
|
||||||
@@ -440,9 +657,7 @@ const togglePanel = () => {
|
|||||||
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
|
const sessionOverride = window.sessionStorage.getItem('debug:video-gating')
|
||||||
const envDisableGating = import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
const envDisableGating = import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true'
|
||||||
// sessionOverride: null=跟随env, 'enabled'=强制首次(无进度条/跳过), 'disabled'=强制关闭(有进度条/跳过)
|
// sessionOverride: null=跟随env, 'enabled'=强制首次(无进度条/跳过), 'disabled'=强制关闭(有进度条/跳过)
|
||||||
const isGatingOn = sessionOverride !== null
|
const isGatingOn = sessionOverride !== null ? sessionOverride === 'enabled' : !envDisableGating
|
||||||
? sessionOverride === 'enabled'
|
|
||||||
: !envDisableGating
|
|
||||||
|
|
||||||
const gatingBtn = document.createElement('button')
|
const gatingBtn = document.createElement('button')
|
||||||
gatingBtn.type = 'button'
|
gatingBtn.type = 'button'
|
||||||
@@ -454,9 +669,7 @@ const togglePanel = () => {
|
|||||||
? '1px solid rgba(255,200,80,0.5)'
|
? '1px solid rgba(255,200,80,0.5)'
|
||||||
: '1px solid rgba(100,200,120,0.5)'
|
: '1px solid rgba(100,200,120,0.5)'
|
||||||
gatingBtn.style.borderRadius = '4px'
|
gatingBtn.style.borderRadius = '4px'
|
||||||
gatingBtn.style.background = isGatingOn
|
gatingBtn.style.background = isGatingOn ? 'rgba(255,180,60,0.2)' : 'rgba(80,200,100,0.18)'
|
||||||
? 'rgba(255,180,60,0.2)'
|
|
||||||
: 'rgba(80,200,100,0.18)'
|
|
||||||
gatingBtn.style.color = isGatingOn ? '#ffd080' : '#99e8aa'
|
gatingBtn.style.color = isGatingOn ? '#ffd080' : '#99e8aa'
|
||||||
gatingBtn.style.fontSize = '11px'
|
gatingBtn.style.fontSize = '11px'
|
||||||
gatingBtn.style.padding = '4px 8px'
|
gatingBtn.style.padding = '4px 8px'
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quiz-4',
|
id: 'quiz-4',
|
||||||
time: 60 * 5 + 40.5,
|
time: 60 * 5 + 42.5,
|
||||||
title: '题目4',
|
title: '题目4',
|
||||||
prompt: '【多选】新时代青年学习“与时俱进”的智慧能够具体落实为:',
|
prompt: '【多选】新时代青年学习“与时俱进”的智慧能够具体落实为:',
|
||||||
type: 'multiple',
|
type: 'multiple',
|
||||||
@@ -182,7 +182,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'quiz-5',
|
id: 'quiz-5',
|
||||||
time: 60 * 5 + 40.5,
|
time: 60 * 5 + 42.5,
|
||||||
title: '题目5',
|
title: '题目5',
|
||||||
prompt: '【多选】新时代的青年要从孙中山先生身上学习哪些精神?',
|
prompt: '【多选】新时代的青年要从孙中山先生身上学习哪些精神?',
|
||||||
type: 'multiple',
|
type: 'multiple',
|
||||||
@@ -315,7 +315,6 @@ const SunzhongshanInternalization = () => {
|
|||||||
})
|
})
|
||||||
const cb = resumeCallback
|
const cb = resumeCallback
|
||||||
setResumeCallback(null)
|
setResumeCallback(null)
|
||||||
triggerPuzzleAnimation(15)
|
|
||||||
cb?.()
|
cb?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -404,7 +403,12 @@ const SunzhongshanInternalization = () => {
|
|||||||
setActiveSuccess(null)
|
setActiveSuccess(null)
|
||||||
const cb = resumeCallback
|
const cb = resumeCallback
|
||||||
setResumeCallback(null)
|
setResumeCallback(null)
|
||||||
triggerPuzzleAnimation(16)
|
// quiz-2 完成后掉落碎片 15,最后一个题目(quiz-5)完成后掉落碎片 16
|
||||||
|
if (item.id === 'quiz-2') {
|
||||||
|
triggerPuzzleAnimation(15)
|
||||||
|
} else if (item.id === 'quiz-5') {
|
||||||
|
triggerPuzzleAnimation(16)
|
||||||
|
}
|
||||||
cb?.()
|
cb?.()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user