Skip to content

Commit b6735e2

Browse files
committed
fix(3d): 主题切换/场景切换手动渲染修复(docked冻结)+routeToModuleId抽离
1 parent 1119e79 commit b6735e2

4 files changed

Lines changed: 98 additions & 21 deletions

File tree

‎client/src/components/layout/AppLayout.tsx‎

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @ai-context: 布局组件:AppLayout。
33
*/
4-
import { useEffect, useLayoutEffect, useState } from 'react';
4+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
55
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
66
import { motion, MotionConfig, AnimatePresence } from 'framer-motion';
77
import { Home } from 'lucide-react';
@@ -17,7 +17,7 @@ import { SceneTransition } from '@/lib/3d/scenes/SceneTransition';
1717
import { SpatialNav } from '@/lib/3d/navigation/SpatialNav';
1818
import { MobileNavGrid } from '@/lib/3d/scenes/MobileNavGrid';
1919
import { FunctionalOverlay } from '@/components/overlay/FunctionalOverlay';
20-
import { useOrbitalStore, MODULE_POSITIONS } from '@/lib/3d/navigation/OrbitalStore';
20+
import { useOrbitalStore, routeToModuleId } from '@/lib/3d/navigation/OrbitalStore';
2121
import { OnboardingOverlay } from '@/components/onboarding/OnboardingOverlay';
2222
import { ModuleTourToast } from '@/components/onboarding/ModuleTourToast';
2323
import { HelpCenter } from '@/components/onboarding/HelpCenter';
@@ -28,9 +28,47 @@ import { FirstDiveGate } from '@/features/onboarding/firstDive/FirstDiveGate';
2828
import { usePerformanceModeStore } from '@/lib/performance/usePerformanceMode';
2929
import { 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: /^\/pomodoro\/(stats|settings)$/, parent: '/pomodoro' },
42+
// Notes 子页面
43+
{ pattern: /^\/notes\/graph$/, parent: '/notes' },
44+
{ pattern: /^\/notes\/([^/]+)$/, parent: '/notes' },
45+
// Flashcards 子页面(二级子路由回到所属牌组详情)
46+
{ pattern: /^\/flashcards\/([^/]+)\/(study|generative-review)$/, parent: (m) => `/flashcards/${m[1]}` },
47+
{ pattern: /^\/flashcards\/([^/]+)$/, parent: '/flashcards' },
48+
// Feynman 子页面
49+
{ pattern: /^\/feynman\/([^/]+)$/, parent: '/feynman' },
50+
{ pattern: /^\/socratic$/, parent: '/feynman' },
51+
// SOP 子页面
52+
{ pattern: /^\/sop\/editor/, parent: '/sop' },
53+
// 非模块页面(settings/analytics/settling/inbox/certificate -> 仪表盘)
54+
{ pattern: /^\/(settings|analytics|settling|inbox|certificate)$/, 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+
3166
export 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 = /^\/notes\/[^/]+$/.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 }}

‎client/src/lib/3d/core/SceneProvider.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ interface SceneProviderProps {
4444
* 确保主题切换时雾色和背景色同步更新
4545
*/
4646
function ThemeAwareEnvironment() {
47-
const { scene } = useThree();
47+
const { gl, scene, camera } = useThree();
4848
const theme = useSceneTheme();
4949

5050
useEffect(() => {
@@ -55,7 +55,13 @@ function ThemeAwareEnvironment() {
5555
scene.fog = new THREE.FogExp2('#e8f4f8', 0.015);
5656
scene.background = new THREE.Color('#e8f4f8');
5757
}
58-
}, [theme, scene]);
58+
// docked 相位 frameloop='never' 下渲染循环已冻结,invalidate() 早退不触发
59+
// 重绘——主题切换后 scene 对象虽已更新但画布仍显示旧帧(背景不立即生效)。
60+
// 延迟到宏任务手动渲染一帧:确保同批 theme 消费者的 Three 属性更新全部
61+
// 提交后再绘制,一次性开销不破坏冻结策略(内测反馈修复)。
62+
const timer = setTimeout(() => gl.render(scene, camera), 0);
63+
return () => clearTimeout(timer);
64+
}, [theme, scene, camera, gl]);
5965

6066
return null;
6167
}

‎client/src/lib/3d/navigation/OrbitalStore.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,20 @@ export const MODULE_POSITIONS: ModulePosition[] = [
5252
{ id: 'classroom', position: [-2, -3, -2], route: '/classroom', label: '回声定位' },
5353
];
5454

55+
/**
56+
* 路由 → 模块ID 映射
57+
* 与 syncWithRoute 中的映射逻辑一致,供外部(如数字键处理器)使用
58+
*/
59+
export function routeToModuleId(pathname: string): ModuleId | null {
60+
const module = MODULE_POSITIONS.find(m => {
61+
if (m.route === '/') return pathname === '/';
62+
return pathname === m.route || pathname.startsWith(m.route + '/');
63+
});
64+
return module
65+
? module.id
66+
: (pathname === '/settings' || pathname === '/analytics') ? 'dashboard' : null;
67+
}
68+
5569
/** 导航洪泛防护:enterModule 最小调用间隔。
5670
* Chromium 对客户端导航(pushState/hash)有洪泛保护(crbug.com/1038223),
5771
* 超阈后导航会被限流丢弃——此时轨道状态已迁移而路由未变,
@@ -97,14 +111,7 @@ export const useOrbitalStore = create<OrbitalState>((set, get) => ({
97111
setHighlightAll: (v) => set({ highlightAll: v }),
98112
syncWithRoute: (pathname: string) => {
99113
const s = get();
100-
// 精确匹配或前缀匹配(支持子路由如 /flashcards/:deckId/study)
101-
const module = MODULE_POSITIONS.find(m => {
102-
if (m.route === '/') return pathname === '/';
103-
return pathname === m.route || pathname.startsWith(m.route + '/');
104-
});
105-
const targetId: ModuleId | null = module
106-
? module.id
107-
: (pathname === '/settings' || pathname === '/analytics') ? 'dashboard' : null;
114+
const targetId = routeToModuleId(pathname);
108115

109116
if (!targetId) {
110117
set({ currentModule: null, phase: 'overview', overlayVisible: false, isInModule: false });

‎client/src/lib/3d/scenes/SceneTransition.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @ai-context: 3D 场景:SceneTransition。
1010
*/
1111
import { useRef, useState, useEffect } from 'react';
12-
import { useFrame } from '@react-three/fiber';
12+
import { useFrame, useThree } from '@react-three/fiber';
1313
import * as THREE from 'three';
1414
import { useSceneTheme } from '../hooks/useSceneTheme';
1515
import { DeepSeaWorld } from './DeepSeaWorld';
@@ -22,6 +22,7 @@ export interface SceneTransitionProps {
2222
const TRANSITION_DURATION = 0.5;
2323

2424
export function SceneTransition({ children }: SceneTransitionProps) {
25+
const { gl, scene, camera } = useThree();
2526
const theme = useSceneTheme();
2627
const isTransitioning = useRef(false);
2728
const transitionProgress = useRef(0);
@@ -55,10 +56,14 @@ export function SceneTransition({ children }: SceneTransitionProps) {
5556
if (auroraGroupRef.current) auroraGroupRef.current.visible = !isDeepSea;
5657
setDeepSeaVisible(isDeepSea);
5758
setAuroraVisible(!isDeepSea);
59+
// docked 相位 frameloop='never' 下渲染循环冻结:可见性虽已切换但无帧
60+
// 绘制,canvas 仍显示旧场景(内测反馈:主题切换背景不生效)。
61+
// 手动渲染一帧让场景切换立即呈现,一次性开销不破坏冻结策略。
62+
gl.render(scene, camera);
5863
}, TRANSITION_DURATION * 1000 + 100);
5964

6065
return () => clearTimeout(timer);
61-
}, [theme]);
66+
}, [theme, gl, scene, camera]);
6267

6368
useFrame((_, delta) => {
6469
if (!isTransitioning.current) return;

0 commit comments

Comments
 (0)