1212 * 确认后 stop_live_session 再 close,取消则采集继续。
1313 * ai:task-update 全局 toast(REQ-145 第二通道)与采集状态无关,保留。
1414 */
15- import { useEffect , useRef , useState } from "react" ;
15+ import { lazy , Suspense , useEffect , useRef , useState } from "react" ;
1616import { listen } from "@tauri-apps/api/event" ;
1717import { invoke } from "@tauri-apps/api/core" ;
1818import { confirm } from "@tauri-apps/plugin-dialog" ;
1919import { getCurrentWindow } from "@tauri-apps/api/window" ;
20+ // 批 2 包体治理:课堂页(默认页)**保持静态 import**。
21+ // @ai -context: 它是首屏必渲染的页面 —— 改成 lazy 只是把同一批字节挪进动态 chunk,
22+ // 首屏仍要多一次 chunk 往返,且预算读数会失真(控制方 2026-09-12 裁决 2 末条认可该判断)。
2023import ClassroomPage from "./pages/ClassroomPage" ;
21- import NotesPage from "./pages/NotesPage" ;
22- import SessionsPage from "./pages/SessionsPage" ;
24+ // 批 2 包体治理:其余 8 个页面从静态 import 改为按页动态 import。
25+ // @ai -context: 「保留挂载(TD-004)」的语义按页保留 —— **访问过的页常驻、永不卸载**;
26+ // 只是「从未访问过的页」不再进入首屏 module graph。
27+ // @ai -context: 不引入路由库(规格 §3 红线 2):入口仍是 useState<Page> + NAV_ITEMS,
28+ // 只是每个页面成为一个独立 chunk。
29+ const NotesPage = lazy ( ( ) => import ( "./pages/NotesPage" ) ) ;
30+ const SessionsPage = lazy ( ( ) => import ( "./pages/SessionsPage" ) ) ;
2331// v0.20.5:行动域页(做——行动中心独立成页,意图分层)
24- import ActionPage from "./pages/ActionPage" ;
32+ const ActionPage = lazy ( ( ) => import ( "./pages/ActionPage" ) ) ;
2533// v0.20.10(批 5,用户问题 6):复习域页(练——复习面独立顶层 Tab;spec §9 二期兑现)
26- import ReviewPage from "./pages/ReviewPage" ;
34+ const ReviewPage = lazy ( ( ) => import ( "./pages/ReviewPage" ) ) ;
2735// v0.16.0:AI 对话页(纯聊天 + AI 任务对话视图——DSH 交互范式)
28- import ChatPage from "./pages/ChatPage" ;
36+ const ChatPage = lazy ( ( ) => import ( "./pages/ChatPage" ) ) ;
2937// 2026-08-21 用户需求:设置页(课堂助手设置类面板迁出,单页滚动+分组)
30- import SettingsPage from "./pages/SettingsPage" ;
38+ const SettingsPage = lazy ( ( ) => import ( "./pages/SettingsPage" ) ) ;
3139// v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不入每日复习面)
32- import KnowledgePage from "./pages/KnowledgePage" ;
40+ const KnowledgePage = lazy ( ( ) => import ( "./pages/KnowledgePage" ) ) ;
3341// v0.18.0:学习目标页(意图层——独立 Tab,零叙事元素)
34- import GoalsPage from "./pages/GoalsPage" ;
42+ const GoalsPage = lazy ( ( ) => import ( "./pages/GoalsPage" ) ) ;
3543// REQ-274(v0.19.4):全局 AI 对话面板(丙案——按需唤起 + 内容保活)
3644import AiConversationDock from "./components/AiConversationDock" ;
3745import AppErrorBoundary from "./components/AppErrorBoundary" ;
@@ -92,10 +100,42 @@ function App() {
92100 ) ;
93101}
94102
103+ /**
104+ * PageSlot — 页面容器:首访挂载 + 保活 + display 门控 + 独立 Suspense。
105+ *
106+ * @ai -context: 批 2 包体治理的挂载闸门,也是「保留挂载」语义的唯一实现点。
107+ * · mounted=false ⇒ 整棵子树不渲染 ⇒ 该页的 lazy chunk **不会被请求**(首屏收益的来源);
108+ * · mounted=true 之后永不回到 false ⇒ 已访问页面常驻(TD-004 保活语义,状态与事件监听不重置);
109+ * · 每页一个独立 Suspense(fallback=null):只有**新挂载**的页会挂起,
110+ * 已经可见的页不会因为邻居加载而被替换成 fallback(避免可见的闪烁)。
111+ * @ai -context: 为什么 fallback 是 null 而不是原语层的 Loading:本批是**尺寸治理批**,
112+ * 引入原语会把它连同 CSS 一起拉进首屏,与目标冲突;「首访加载态」登记给批 3/4
113+ * (壳层与加载原语一起做),见计划 Task 11 的瓶颈清单。
114+ * @ai -context: 动态 import 失败时 React 会把它抛到最近的错误边界 —— App.tsx 的
115+ * AppErrorBoundary 仍在最外层包着 MainShell,因此「chunk 加载失败」有兜底 UI,不会白屏。
116+ * 副作用:无。边界:children 是懒组件元素,未 mounted 时不会被 React 渲染 ⇒ 不触发 dynamic import。
117+ */
118+ function PageSlot ( { show, mounted, children } : { show : boolean ; mounted : boolean ; children : React . ReactNode } ) {
119+ if ( ! mounted ) return null ;
120+ return (
121+ < div style = { { flex : 1 , display : show ? "block" : "none" , overflow : "hidden" } } >
122+ < Suspense fallback = { null } > { children } </ Suspense >
123+ </ div >
124+ ) ;
125+ }
126+
95127/** 主导航壳(唯一实例由 App 包入 provider——同窗单实例纪律;状态与事件监听
96128 * 保留挂载语义不变,TD-004) */
97129function MainShell ( ) {
98130 const [ page , setPage ] = useState < Page > ( "classroom" ) ;
131+ // 批 2:首访挂载集合。初始只有默认页 —— 其余页面在首次被选中后的下一帧挂载。
132+ // @ai -context: 用 useState + useEffect 而不是「渲染期 ref.add」:渲染期改 ref 在
133+ // StrictMode 下虽幂等,但仍是渲染副作用;本仓 React 19 StrictMode 会双调用渲染。
134+ // 代价是切页时首帧内容区为空(本来也要等 chunk 下载),无观感回归。
135+ const [ mountedPages , setMountedPages ] = useState < ReadonlySet < Page > > ( ( ) => new Set < Page > ( [ "classroom" ] ) ) ;
136+ useEffect ( ( ) => {
137+ setMountedPages ( ( prev ) => ( prev . has ( page ) ? prev : new Set ( prev ) . add ( page ) ) ) ;
138+ } , [ page ] ) ;
99139 // 2026-08 A4:跨页直达目标会话(课堂助手融合完成 → 会话页自动打开详情)
100140 const [ focusSessionId , setFocusSessionId ] = useState < number | null > ( null ) ;
101141 // v0.7.1:跨页直达目标笔记(会话页"查看笔记" → 笔记页自动选中并滚动可见)
@@ -309,16 +349,16 @@ function MainShell() {
309349 { /* 页面区(TD-004:保留挂载 + display 切换——页面切换不重挂载,
310350 避免 ClassroomPage 每次进入重复窗口枚举 100-500ms 停顿;状态与事件监听保留) */ }
311351 < main style = { { flex : 1 , minHeight : 0 , display : "flex" } } >
312- < div style = { { flex : 1 , display : page === "classroom" ? "block" : "none" , overflow : "hidden" } } >
352+ < PageSlot show = { page === "classroom" } mounted = { mountedPages . has ( "classroom" ) } >
313353 { /* 2026-08 A4:融合完成直达会话(onOpenSessions 跳转 + focusSessionId 定位) */ }
314354 < ClassroomPage
315355 onOpenSessions = { ( id ) => {
316356 setFocusSessionId ( id ) ;
317357 setPage ( "sessions" ) ;
318358 } }
319359 />
320- </ div >
321- < div style = { { flex : 1 , display : page === "sessions" ? "block" : "none" , overflow : "hidden" } } >
360+ </ PageSlot >
361+ < PageSlot show = { page === "sessions" } mounted = { mountedPages . has ( "sessions" ) } >
322362 { /* v0.7.1:active 驱动列表刷新(display:none 挂载不刷新根治)+ 查看笔记跨页直达 */ }
323363 < SessionsPage
324364 focusSessionId = { focusSessionId }
@@ -332,8 +372,8 @@ function MainShell() {
332372 active = { page === "sessions" }
333373 onOpenNote = { ( id ) => openNotePlain ( id ) }
334374 />
335- </ div >
336- < div style = { { flex : 1 , display : page === "notes" ? "block" : "none" , overflow : "hidden" } } >
375+ </ PageSlot >
376+ < PageSlot show = { page === "notes" } mounted = { mountedPages . has ( "notes" ) } >
337377 { /* v0.7.1:focusNoteId 定位 + 来源会话反向跳转(与课堂助手 onOpenSessions 同模式) */ }
338378 < NotesPage
339379 focusNoteId = { focusNoteId }
@@ -357,22 +397,22 @@ function MainShell() {
357397 setPage ( "sessions" ) ;
358398 } }
359399 />
360- </ div >
400+ </ PageSlot >
361401 { /* v0.20.5:行动域页(保活挂载 + active 门控切回重载——TD-004 模式) */ }
362- < div style = { { flex : 1 , display : page === "action" ? "block" : "none" , overflow : "hidden" } } >
402+ < PageSlot show = { page === "action" } mounted = { mountedPages . has ( "action" ) } >
363403 < ActionPage active = { page === "action" } />
364- </ div >
404+ </ PageSlot >
365405 { /* v0.20.10(批 5):复习域页(保活挂载 + active 门控切回重载——
366406 闪卡域无事件总线,TD-004 模式同 ActionPage;focusReviewGroupId 深链
367407 组预选,消费后清空) */ }
368- < div style = { { flex : 1 , display : page === "review" ? "block" : "none" , overflow : "hidden" } } >
408+ < PageSlot show = { page === "review" } mounted = { mountedPages . has ( "review" ) } >
369409 < ReviewPage
370410 active = { page === "review" }
371411 focusGroupId = { focusReviewGroupId }
372412 onFocusGroupConsumed = { ( ) => setFocusReviewGroupId ( null ) }
373413 />
374- </ div >
375- < div style = { { flex : 1 , display : page === "chat" ? "block" : "none" , overflow : "hidden" } } >
414+ </ PageSlot >
415+ < PageSlot show = { page === "chat" } mounted = { mountedPages . has ( "chat" ) } >
376416 { /* v0.16.0:AI 对话页——跨页跳转复用 focus 机制(任务对话引用 → 会话/笔记/设置)。
377417 2026-09-09 批 1:active 门控透传——保活挂载下切回重同步(别页发起/完成的
378418 AI 任务本页无感知;SessionsPage/ActionPage 同款 active 语义) */ }
@@ -397,8 +437,8 @@ function MainShell() {
397437 setPage ( "sessions" ) ;
398438 } }
399439 />
400- </ div >
401- < div style = { { flex : 1 , display : page === "knowledge" ? "block" : "none" , overflow : "hidden" } } >
440+ </ PageSlot >
441+ < PageSlot show = { page === "knowledge" } mounted = { mountedPages . has ( "knowledge" ) } >
402442 { /* v0.13.1:知识体系页(三时钟纪律——体系进周/季度视图,不与每日复习面混排)
403443 v0.13.7:focusSystemId 跨页直达(组行徽标/结算简报 → 自动选中体系) */ }
404444 < KnowledgePage
@@ -407,16 +447,16 @@ function MainShell() {
407447 onOpenNote = { ( id ) => { setFocusNoteId ( id ) ; setPage ( "notes" ) ; } }
408448 onOpenGroup = { ( id ) => { setFocusGroupId ( id ) ; setPage ( "notes" ) ; } }
409449 />
410- </ div >
411- < div style = { { flex : 1 , display : page === "goals" ? "block" : "none" , overflow : "hidden" } } >
450+ </ PageSlot >
451+ < PageSlot show = { page === "goals" } mounted = { mountedPages . has ( "goals" ) } >
412452 { /* v0.18.0:学习目标页(意图层——列表是导航不是仪表盘) */ }
413453 < GoalsPage />
414- </ div >
415- < div style = { { flex : 1 , display : page === "settings" ? "block" : "none" , overflow : "hidden" } } >
454+ </ PageSlot >
455+ < PageSlot show = { page === "settings" } mounted = { mountedPages . has ( "settings" ) } >
416456 { /* 2026-08-21:设置页(保留挂载——面板状态不因切页重置;TD-004 同模式);
417457 active 透传——学习库段 8s 轮询按可见性门控(v0.19.1 审查 L2) */ }
418458 < SettingsPage active = { page === "settings" } />
419- </ div >
459+ </ PageSlot >
420460 </ main >
421461 { /* REQ-274:全局 AI 对话面板(常驻挂载——开合仅切 display,选中态/后台任务保活) */ }
422462 < AiConversationDock
0 commit comments