diff --git a/src/App.tsx b/src/App.tsx index 398d397..125dcae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -67,6 +67,11 @@ function AppContent() { console.error('Failed to load puzzle store', e) } finally { await applyStoredProgressionDebugMode() + // 仅在测试包启用时应用阶段覆盖(防止 sessionStorage 残留污染生产数据) + if (import.meta.env.VITE_DEBUG_ENTRY === 'true') { + const { applyStoredStageOverrides } = await import('@/debug/progressionDebug') + applyStoredStageOverrides() + } usePuzzleStore.getState().setLoaded(true) setBootstrapDone(true) } diff --git a/src/components/layout/ResidenceLayout.tsx b/src/components/layout/ResidenceLayout.tsx index 104fe96..5e8b2e7 100644 --- a/src/components/layout/ResidenceLayout.tsx +++ b/src/components/layout/ResidenceLayout.tsx @@ -12,6 +12,7 @@ import { Image, PuzzleSystem, flyToAnimation } from '@/components' import { usePuzzleStore } from '@/store/puzzleStore' import { getPuzzlePieceData } from '@/config/puzzleData' import PuzzlePiecePortal from '@/components/puzzle/PuzzlePiecePortal' +import { setPreviousPath } from '@/hooks/useResidenceContinue' /** * 支持拼图盒功能的故居列表 @@ -349,7 +350,8 @@ const ResidenceLayoutContent = () => { setIsVideoFadingOut(false) } - // 更新前一个路径 + // 更新前一个路径(供 useResidenceContinue 判断是否从场景回来) + setPreviousPath(previousPathRef.current) previousPathRef.current = location.pathname }, [location.pathname, videoSrc, isForwardNavigation]) diff --git a/src/components/puzzle/PuzzlePiecePortal.tsx b/src/components/puzzle/PuzzlePiecePortal.tsx index 483933f..fb220d3 100644 --- a/src/components/puzzle/PuzzlePiecePortal.tsx +++ b/src/components/puzzle/PuzzlePiecePortal.tsx @@ -1,6 +1,6 @@ import { forwardRef, useEffect, useState } from 'react' -import { createPortal } from 'react-dom' import { motion } from 'framer-motion' +import ScaledPortal from '@/components/layout/ScaledPortal' import FlipActionButton from '@/components/button/FlipActionButton' import PuzzlePieceDisplay from './PuzzlePieceDisplay' @@ -38,144 +38,144 @@ const PuzzlePiecePortal = forwardRef( return () => clearTimeout(timer) }, [pieceId]) - return createPortal( -
- {awaitingCollect && !hidden && !confirming && ( - - )} - + return ( +
- + )} + + - - {awaitingCollect && !hidden && ( - - {puzzlePieceTitle && ( -
- {puzzlePieceTitle.split('\n').map((line) => ( -
{line}
- ))} -
- )} - - - 收下 - -
- )} -
, - document.body + +
+
+ + {awaitingCollect && !hidden && ( + + {puzzlePieceTitle && ( +
+ {puzzlePieceTitle.split('\n').map((line) => ( +
{line}
+ ))} +
+ )} + + + 收下 + +
+ )} + + ) } ) diff --git a/src/components/video/video.tsx b/src/components/video/video.tsx index 70351d8..47f3ca3 100644 --- a/src/components/video/video.tsx +++ b/src/components/video/video.tsx @@ -296,7 +296,11 @@ const Video = forwardRef( for (const anchor of anchors) { if (!isAnchorTriggerable(anchor)) continue 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) break } @@ -310,7 +314,7 @@ const Video = forwardRef( for (const anchor of anchors) { if (!isAnchorTriggerable(anchor)) continue const timeDiff = targetTime - anchor.time - if (timeDiff >= 0 && timeDiff < 0.5) { + if (timeDiff >= 0) { triggerAnchor(anchor, videoRef.current) break } @@ -577,8 +581,7 @@ const Video = forwardRef( const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0 // 视频首次观看限制:env 默认值可被调试面板运行时覆盖 - const envDisableGating = - import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true' + 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 diff --git a/src/debug/progressionDebug.ts b/src/debug/progressionDebug.ts index 65735cc..c39d1b3 100644 --- a/src/debug/progressionDebug.ts +++ b/src/debug/progressionDebug.ts @@ -1,10 +1,10 @@ import { puzzleDataConfig } from '@/config/puzzleData' import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences' import { usePuzzleStore } from '@/store/puzzleStore' -import { savePuzzleStore } from '@/store/puzzleStore' const MODE_KEY = 'debug:progression-mode' const BACKUP_KEY = 'debug:progression-real-puzzles-backup' +const STAGE_OVERRIDES_KEY = 'debug:stage-overrides' const POSITION_KEY = 'debug:progression-badge-position' const BADGE_ID = 'progression-debug-badge' 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 currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set() const collected = stagePieceIds.filter((id) => currentPieces.has(id)).length @@ -136,31 +139,238 @@ const buildPuzzlesWithAncestors = (residence: string): Record>, 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> +) => { + 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>> + +/** 读取当前 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 stagePieceIds = getStagePieceIds(residence)[stageKey] if (stagePieceIds.length === 0) return + const allStageIds = getStagePieceIds(residence) const currentPieces = usePuzzleStore.getState().puzzles[residence] ?? new Set() const allCollected = stagePieceIds.every((id) => currentPieces.has(id)) const puzzles = buildPuzzlesWithAncestors(residence) + const overrides = getStoredStageOverrides() + 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() for (const id of currentPieces) { - if (!stagePieceIds.includes(id)) { + if (!removalIds.has(id)) { remaining.add(id) } } 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 { - 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> = { + ...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() + + 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 }) - savePuzzleStore(puzzles) } const getStoredMode = (): DebugMode => { @@ -334,11 +544,19 @@ const removePanel = () => { const renderStageButton = ( residence: string, displayName: string, - stage: StageConfig, + stage: StageConfig ): HTMLButtonElement => { const { collected, total } = getStageProgress(residence, stage.key) const isFull = total > 0 && collected >= total + // 点击时自动补齐前序阶段(体悟→对话+体悟,内化→对话+体悟+内化) + const autoFillHint = + stage.key === 'reflection' && total > 0 + ? '(自动补齐对话)' + : stage.key === 'internalization' && total > 0 + ? '(自动补齐对话+体悟)' + : '' + const btn = document.createElement('button') btn.type = 'button' btn.textContent = `${stage.label} ${collected}/${total}` @@ -360,24 +578,23 @@ const renderStageButton = ( btn.style.borderColor = 'rgba(100,220,120,0.5)' btn.style.background = 'rgba(80,200,100,0.18)' btn.style.color = '#99e8aa' - btn.title = `「${stage.label}」已完成(${total} 块)。点击移除。` + btn.title = `「${stage.label}」已完成(${total} 块)。点击移除(级联移除后序阶段)。` } else if (collected > 0) { btn.style.borderColor = 'rgba(255,200,80,0.5)' btn.style.background = 'rgba(255,180,60,0.15)' btn.style.color = '#ffd080' - btn.title = `「${stage.label}」部分完成(${collected}/${total})。点击补全。` + btn.title = `「${stage.label}」部分完成(${collected}/${total})。点击补全${autoFillHint}。` } else { btn.style.borderColor = 'rgba(100,180,255,0.4)' btn.style.background = 'rgba(80,150,255,0.12)' btn.style.color = '#99ccff' - btn.title = `「${stage.label}」未开始。点击补全(${total} 块)。` + btn.title = `「${stage.label}」未开始。点击补全${autoFillHint}(${total} 块)。` } if (total > 0) { btn.addEventListener('click', (e) => { e.stopPropagation() toggleResidenceStage(residence, stage.key) - // 即时刷新面板按钮状态 removePanel() togglePanel() }) @@ -440,9 +657,7 @@ const togglePanel = () => { const sessionOverride = window.sessionStorage.getItem('debug:video-gating') const envDisableGating = import.meta.env.VITE_DISABLE_FIRST_WATCH_VIDEO_GATING === 'true' // sessionOverride: null=跟随env, 'enabled'=强制首次(无进度条/跳过), 'disabled'=强制关闭(有进度条/跳过) - const isGatingOn = sessionOverride !== null - ? sessionOverride === 'enabled' - : !envDisableGating + const isGatingOn = sessionOverride !== null ? sessionOverride === 'enabled' : !envDisableGating const gatingBtn = document.createElement('button') gatingBtn.type = 'button' @@ -454,9 +669,7 @@ const togglePanel = () => { ? '1px solid rgba(255,200,80,0.5)' : '1px solid rgba(100,200,120,0.5)' gatingBtn.style.borderRadius = '4px' - gatingBtn.style.background = isGatingOn - ? 'rgba(255,180,60,0.2)' - : 'rgba(80,200,100,0.18)' + gatingBtn.style.background = isGatingOn ? 'rgba(255,180,60,0.2)' : 'rgba(80,200,100,0.18)' gatingBtn.style.color = isGatingOn ? '#ffd080' : '#99e8aa' gatingBtn.style.fontSize = '11px' gatingBtn.style.padding = '4px 8px' diff --git a/src/pages/sunzhongshan/internalization.tsx b/src/pages/sunzhongshan/internalization.tsx index 6da52c8..df07d6b 100644 --- a/src/pages/sunzhongshan/internalization.tsx +++ b/src/pages/sunzhongshan/internalization.tsx @@ -152,7 +152,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-4', - time: 60 * 5 + 40.5, + time: 60 * 5 + 42.5, title: '题目4', prompt: '【多选】新时代青年学习“与时俱进”的智慧能够具体落实为:', type: 'multiple', @@ -182,7 +182,7 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ }, { id: 'quiz-5', - time: 60 * 5 + 40.5, + time: 60 * 5 + 42.5, title: '题目5', prompt: '【多选】新时代的青年要从孙中山先生身上学习哪些精神?', type: 'multiple', @@ -315,7 +315,6 @@ const SunzhongshanInternalization = () => { }) const cb = resumeCallback setResumeCallback(null) - triggerPuzzleAnimation(15) cb?.() }} /> @@ -404,7 +403,12 @@ const SunzhongshanInternalization = () => { setActiveSuccess(null) const cb = resumeCallback 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?.() }} />