11/**
22 * @ai -context: 布局组件:AppLayout。
33 */
4- import { useEffect , useLayoutEffect , useState } from 'react' ;
4+ import { useEffect , useLayoutEffect , useRef , useState } from 'react' ;
55import { Outlet , useLocation , useNavigate } from 'react-router-dom' ;
66import { motion , MotionConfig , AnimatePresence } from 'framer-motion' ;
77import { Home } from 'lucide-react' ;
@@ -17,7 +17,7 @@ import { SceneTransition } from '@/lib/3d/scenes/SceneTransition';
1717import { SpatialNav } from '@/lib/3d/navigation/SpatialNav' ;
1818import { MobileNavGrid } from '@/lib/3d/scenes/MobileNavGrid' ;
1919import { FunctionalOverlay } from '@/components/overlay/FunctionalOverlay' ;
20- import { useOrbitalStore , MODULE_POSITIONS } from '@/lib/3d/navigation/OrbitalStore' ;
20+ import { useOrbitalStore , routeToModuleId } from '@/lib/3d/navigation/OrbitalStore' ;
2121import { OnboardingOverlay } from '@/components/onboarding/OnboardingOverlay' ;
2222import { ModuleTourToast } from '@/components/onboarding/ModuleTourToast' ;
2323import { HelpCenter } from '@/components/onboarding/HelpCenter' ;
@@ -28,9 +28,47 @@ import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
2828import { usePerformanceModeStore } from '@/lib/performance/usePerformanceMode' ;
2929import { PERFORMANCE_MODE_CONFIG } from '@/lib/performance/performanceMode' ;
3030
31+ /**
32+ * 路由层级映射:子路由 -> 父路由
33+ * 在 ESC 处理器中用于"向上导航一级"而非直接退出模块
34+ */
35+ function findParentRoute ( pathname : string ) : string | null {
36+ const patterns : Array < {
37+ pattern : RegExp ;
38+ parent : string | ( ( match : RegExpMatchArray ) => string ) ;
39+ } > = [
40+ // Pomodoro 子页面
41+ { pattern : / ^ \/ p o m o d o r o \/ ( s t a t s | s e t t i n g s ) $ / , parent : '/pomodoro' } ,
42+ // Notes 子页面
43+ { pattern : / ^ \/ n o t e s \/ g r a p h $ / , parent : '/notes' } ,
44+ { pattern : / ^ \/ n o t e s \/ ( [ ^ / ] + ) $ / , parent : '/notes' } ,
45+ // Flashcards 子页面(二级子路由回到所属牌组详情)
46+ { pattern : / ^ \/ f l a s h c a r d s \/ ( [ ^ / ] + ) \/ ( s t u d y | g e n e r a t i v e - r e v i e w ) $ / , parent : ( m ) => `/flashcards/${ m [ 1 ] } ` } ,
47+ { pattern : / ^ \/ f l a s h c a r d s \/ ( [ ^ / ] + ) $ / , parent : '/flashcards' } ,
48+ // Feynman 子页面
49+ { pattern : / ^ \/ f e y n m a n \/ ( [ ^ / ] + ) $ / , parent : '/feynman' } ,
50+ { pattern : / ^ \/ s o c r a t i c $ / , parent : '/feynman' } ,
51+ // SOP 子页面
52+ { pattern : / ^ \/ s o p \/ e d i t o r / , parent : '/sop' } ,
53+ // 非模块页面(settings/analytics/settling/inbox/certificate -> 仪表盘)
54+ { pattern : / ^ \/ ( s e t t i n g s | a n a l y t i c s | s e t t l i n g | i n b o x | c e r t i f i c a t e ) $ / , parent : '/' } ,
55+ ] ;
56+
57+ for ( const { pattern, parent } of patterns ) {
58+ const match = pathname . match ( pattern ) ;
59+ if ( match ) {
60+ return typeof parent === 'function' ? parent ( match ) : parent ;
61+ }
62+ }
63+ return null ;
64+ }
65+
3166export default function AppLayout ( ) {
3267 const { pathname } = useLocation ( ) ;
3368 const navigate = useNavigate ( ) ;
69+ // 跟踪当前路径(ref 避免 ESC 处理器闭包读到旧值,同时避免加入 effect 依赖)
70+ const pathnameRef = useRef ( pathname ) ;
71+ useEffect ( ( ) => { pathnameRef . current = pathname ; } , [ pathname ] ) ;
3472 // 细粒度 selector 订阅:整 store 订阅会被 hoveredModule 等高频字段变化
3573 // 连带重渲染整棵布局树(含 Outlet 页面),是流畅度关键瓶颈
3674 const isInModule = useOrbitalStore ( ( s ) => s . isInModule ) ;
@@ -46,6 +84,11 @@ export default function AppLayout() {
4684 // 性能模式:静谧(low)档全局减弱 Framer Motion 动画(transform/layout 动画直接到位)
4785 const perfMode = usePerformanceModeStore ( ( s ) => s . mode ) ;
4886 const reduceMotion = PERFORMANCE_MODE_CONFIG [ perfMode ] . reduceMotion ;
87+ // 笔记编辑/图谱路由需要满高面板:面板默认高度由内容决定(auto),
88+ // 高度链 h-full → flex-1 会逐级失效,导致 React Flow 等依赖 100% 高度的
89+ // 画布类组件容器高度为 0(思维导图/自由画布/图谱不渲染)。
90+ // 给这些路由的面板补 !h-full 让高度链恢复(max-h 仍生效)。
91+ const isFullHeightRoute = / ^ \/ n o t e s \/ [ ^ / ] + $ / . test ( pathname ) ;
4992
5093 // 监听 session 过期事件
5194 useSessionExpiry ( ) ;
@@ -91,10 +134,21 @@ export default function AppLayout() {
91134 }
92135
93136 if ( e . key === 'Escape' ) {
94- if ( isInModule ) {
137+ // 层级感知:子路由 -> 父路由,根路由 -> 退出模块
138+ const currentPath = pathnameRef . current ;
139+ const parentRoute = findParentRoute ( currentPath ) ;
140+
141+ if ( parentRoute ) {
142+ // 有父路由 -> 向上导航一级(不退出模块,保持模块 docked 态)
143+ e . preventDefault ( ) ;
144+ navigate ( parentRoute ) ;
145+ } else if ( isInModule ) {
146+ // 已是模块根路由 -> 退出模块回到 3D 概览
147+ e . preventDefault ( ) ;
95148 exitModule ( ) ;
96- } else {
97- // 在 3D 场景模式下按 Esc 返回仪表盘
149+ } else if ( currentPath !== '/' ) {
150+ // 3D 场景模式下按 Esc 返回仪表盘(仅非首页时)
151+ e . preventDefault ( ) ;
98152 enterModule ( 'dashboard' ) ;
99153 navigate ( '/' ) ;
100154 }
@@ -110,8 +164,10 @@ export default function AppLayout() {
110164 if ( moduleKeys [ e . key ] ) {
111165 const route = moduleKeys [ e . key ] ;
112166 // 显式调用 enterModule:同路由 navigate 无效时仍能触发相位迁移(修复 Esc 后重复按键无响应)
113- const mod = MODULE_POSITIONS . find ( m => m . route === route ) ;
114- if ( mod ) enterModule ( mod . id ) ;
167+ // 使用 routeToModuleId 统一映射:对非 MODULE_POSITIONS 成员(如 /settings)
168+ // 也能正确调用 enterModule,避免 navigate 同路由无变化时相位无法恢复
169+ const modId = routeToModuleId ( route ) ;
170+ if ( modId ) enterModule ( modId ) ;
115171 navigate ( route ) ;
116172 }
117173 }
@@ -151,18 +207,21 @@ export default function AppLayout() {
151207 /* 萤火海沟自带全屏暗物质场:透明面板让深海背景透出,遮罩用深海底色实现整屏无缝(其他模块保持毛玻璃) */
152208 panelClassName = { currentModule === 'inspiration'
153209 ? '!bg-transparent !backdrop-blur-none !shadow-none !border-white/5'
154- : undefined }
210+ : isFullHeightRoute
211+ ? '!h-full'
212+ : undefined }
155213 maskClassName = { currentModule === 'inspiration' ? '!bg-[var(--kb-bg-primary)] !backdrop-blur-none' : undefined }
156214 >
157215 { /* 路由级过渡:grid 叠放 + 同期交叉淡入淡出。
158216 历史方案 mode="wait" 要求旧页退出回调交接后才挂载新页;在
159217 reduced-motion / StrictMode(dev) / HMR 等条件下交接会偶发丢失,
160218 新页永不挂载——即内测反馈"回到主页时主页不显示"的根因。
161219 交叉淡入淡出下新页立即挂载、旧页淡出后卸载,退出即使延迟也不阻塞内容展示 */ }
162- < div className = "grid" >
220+ < div className = "grid h-full " >
163221 < AnimatePresence >
164222 < motion . div
165223 key = { pathname }
224+ className = "h-full"
166225 style = { { gridArea : '1 / 1' } }
167226 /* 简单的 opacity 淡入淡出,不影响 3D 场景的相机飞行和停靠时序 */
168227 initial = { { opacity : 0 } }
0 commit comments