11/**
2- * App — 应用导航壳 :课堂助手 / 会话 / 笔记 三个独立页面 。
2+ * App — 应用入口与导航壳 :课堂助手 / 会话 / 笔记 等独立页面 。
33 *
44 * @ai -context: 顶部标签导航 + 页面条件渲染(MVP 不引入路由库,保持轻量);
55 * 页面组件各自管理状态,切换不共享可变状态。
6+ * @ai -context: 批 2b 采集控制单一状态源:主窗 UI 整体包在
7+ * <CaptureStatusProvider> 内(导航徽标/ClassroomPage/右栏同 context),
8+ * 浮窗分支(?float=1)单独实例化同一 provider——每窗口恰一个实例。
69 * @ai -context: ADR-007(REQ-033):本层为全局采集生命周期宿主——
710 * ① 导航栏常驻采集状态徽标(页面切换/最小化后仍可感知采集在跑)
811 * ② 监听 app:close-requested(Rust 侧拦截了关闭)→ 确认框 →
912 * 确认后 stop_live_session 再 close,取消则采集继续。
13+ * ai:task-update 全局 toast(REQ-145 第二通道)与采集状态无关,保留。
1014 */
1115import { useEffect , useRef , useState } from "react" ;
1216import { listen } from "@tauri-apps/api/event" ;
@@ -33,6 +37,9 @@ import CaptureFloatPanel from "./components/CaptureFloatPanel";
3337import CaptureOverlayPanel from "./components/CaptureOverlayPanel" ;
3438// v0.16.1:浏览器痕迹去除(原生右键菜单抑制 + 文本输入应用内右键小菜单)
3539import BrowserChrome from "./components/BrowserChrome" ;
40+ // 批 2b:采集控制单一状态源(每窗口单实例 provider + 消费 hook + reason 文案)
41+ import { CaptureStatusProvider , useCaptureControl } from "./hooks/useLiveCaptureControl" ;
42+ import { pauseReasonLabel } from "./hooks/liveCaptureState" ;
3643import type { AiTaskState } from "./types" ;
3744
3845type Page = "classroom" | "sessions" | "notes" | "action" | "chat" | "knowledge" | "goals" | "settings" ;
@@ -52,20 +59,37 @@ const NAV_ITEMS: { key: Page; label: string }[] = [
5259] ;
5360
5461function App ( ) {
55- // v0.12.0 M3:系统级覆盖层截图窗口入口——URL 带 ?overlay=1 时仅渲染
56- // CaptureOverlayPanel(全屏透明 1:1 框选;不渲染主导航壳;独立窗口)。
57- // eslint-disable-next-line react-hooks/rules-of-hooks
58- if ( new URLSearchParams ( window . location . search ) . get ( "overlay" ) === "1" ) {
62+ // URL per-window 标志早返回(不渲染主导航壳)。批 2b 采集控制单一状态源:
63+ // 主窗与浮窗各持**一份** CaptureStatusProvider(独立 webview 无法共享 context,
64+ // 各自实例化;同一窗口禁止第二实例——双监听双查询即漂移根源)
65+ const query = new URLSearchParams ( window . location . search ) ;
66+ // v0.12.0 M3:系统级覆盖层截图窗口入口(全屏透明 1:1 框选;无采集控制需求)
67+ if ( query . get ( "overlay" ) === "1" ) {
5968 return < CaptureOverlayPanel /> ;
6069 }
61- // v0.12.0 M6:采集浮窗入口——URL 带 ?float=1 时仅渲染 CaptureFloatPanel
62- //(不渲染主导航壳;浮窗独立窗口 alwaysOnTop,加载 index.html?float=1)。
63- // 规则:float 标志 per-window 恒定(URL 不变),故此处 before-hooks 早返回安全;
64- // 它使浮窗不注册主导航的 live:* 监听(数据流由 CaptureFloatPanel 的 hook 持有)。
65- // eslint-disable-next-line react-hooks/rules-of-hooks
66- if ( new URLSearchParams ( window . location . search ) . get ( "float" ) === "1" ) {
67- return < CaptureFloatPanel /> ;
70+ // v0.12.0 M6:采集浮窗入口(独立窗口 alwaysOnTop,加载 index.html?float=1);
71+ // float 标志 per-window 恒定(URL 不变)——早返回安全
72+ if ( query . get ( "float" ) === "1" ) {
73+ return (
74+ < CaptureStatusProvider >
75+ < CaptureFloatPanel />
76+ </ CaptureStatusProvider >
77+ ) ;
6878 }
79+ return (
80+ // v0.8.0 真机白屏防御(2026-08-21):全局错误边界——渲染异常显示错误卡片
81+ // 而非整树卸载白屏(AppErrorBoundary)
82+ < AppErrorBoundary >
83+ < CaptureStatusProvider >
84+ < MainShell />
85+ </ CaptureStatusProvider >
86+ </ AppErrorBoundary >
87+ ) ;
88+ }
89+
90+ /** 主导航壳(唯一实例由 App 包入 provider——同窗单实例纪律;状态与事件监听
91+ * 保留挂载语义不变,TD-004) */
92+ function MainShell ( ) {
6993 const [ page , setPage ] = useState < Page > ( "classroom" ) ;
7094 // 2026-08 A4:跨页直达目标会话(课堂助手融合完成 → 会话页自动打开详情)
7195 const [ focusSessionId , setFocusSessionId ] = useState < number | null > ( null ) ;
@@ -95,11 +119,10 @@ function App() {
95119 // REQ-274:对话面板开合 + 「在对话页继续」直达会话(消费后清空)
96120 const [ dockOpen , setDockOpen ] = useState ( false ) ;
97121 const [ focusChatId , setFocusChatId ] = useState < number | null > ( null ) ;
98- // 全局采集状态(ADR-007:与页面解耦,徽标常驻导航栏)
99- const [ capturing , setCapturing ] = useState ( false ) ;
100- const [ recovering , setRecovering ] = useState ( false ) ;
101- // 2026-08 A1:会话暂停(live:paused/resumed 事件;徽标区分暂停态)
102- const [ paused , setPaused ] = useState ( false ) ;
122+ // 全局采集状态(ADR-007:与页面解耦,徽标常驻导航栏)。
123+ // 批 2b:capturing/recovering/paused 三份本地状态删除——采集控制单一状态源
124+ // (CaptureStatusProvider context;挂载拉取+事件+看门狗全在其内)
125+ const capture = useCaptureControl ( ) ;
103126 // v0.8.0 F2(2026-08-21):AI 任务完成通知——全局监听 ai:task-update,
104127 // 跨页面可见(REQ-145"完成通知"落地;内联卡片之外的第二通道)
105128 const [ aiToast , setAiToast ] = useState < { text : string ; kind : "ok" | "err" } | null > ( null ) ;
@@ -131,52 +154,9 @@ function App() {
131154 } , 3500 ) ;
132155 } ) ,
133156 ) ;
134- // 采集主状态:recording=采集中;stopped/failed=结束(live_session 事件)
135- unlisteners . push (
136- await listen < string > ( "live:status" , ( e ) => {
137- if ( disposed ) return ;
138- setCapturing ( e . payload === "recording" ) ;
139- if ( e . payload !== "recording" ) {
140- setRecovering ( false ) ;
141- setPaused ( false ) ;
142- }
143- } ) ,
144- ) ;
145- // REQ-175(v0.7.5):停止残留兜底——融合开始 = 采集已停的可靠信号
146- // (session:fusing 在会话停止后无条件发出;live:status stopped 可能
147- // 因线程卡死/事件丢失而不到达——会话31 实证"采集中"残留至重启翻案)
148- unlisteners . push (
149- await listen < number > ( "session:fusing" , ( ) => {
150- if ( disposed ) return ;
151- setCapturing ( false ) ;
152- setRecovering ( false ) ;
153- setPaused ( false ) ;
154- } ) ,
155- ) ;
156- // 2026-08 A1:暂停/恢复(全局徽标显示"⏸ 已暂停")
157- unlisteners . push (
158- await listen ( "live:paused" , ( ) => {
159- if ( ! disposed ) setPaused ( true ) ;
160- } ) ,
161- ) ;
162- unlisteners . push (
163- await listen ( "live:resumed" , ( ) => {
164- if ( ! disposed ) setPaused ( false ) ;
165- } ) ,
166- ) ;
167- // 音频自动重连中(ADR-007):会话未死,UI 提示恢复态
168- unlisteners . push (
169- await listen ( "live:recovering" , ( ) => {
170- if ( disposed ) return ;
171- setCapturing ( true ) ;
172- setRecovering ( true ) ;
173- } ) ,
174- ) ;
175- unlisteners . push (
176- await listen ( "live:recovered" , ( ) => {
177- if ( ! disposed ) setRecovering ( false ) ;
178- } ) ,
179- ) ;
157+ // 批 2b:live:status / session:fusing / live:paused / live:resumed /
158+ // live:recovering / live:recovered 监听删除——采集控制状态收敛全部下沉
159+ // CaptureStatusProvider(本壳徽标消费 context;recovering 组合文案同源)
180160 // Rust 侧 CloseRequested 拦截(采集进行中)→ 用户确认后才停止并退出
181161 unlisteners . push (
182162 await listen ( "app:close-requested" , async ( ) => {
@@ -217,10 +197,8 @@ function App() {
217197 } , [ ] ) ;
218198
219199 return (
220- // v0.8.0 真机白屏防御(2026-08-21):全局错误边界——渲染异常显示错误
221- // 卡片而非整树卸载白屏;console 打印调用栈便于定位(AppErrorBoundary)
222- < AppErrorBoundary >
223- < div style = { { height : "100vh" , display : "flex" , flexDirection : "column" , fontFamily : "system-ui, sans-serif" } } >
200+ // 全局错误边界在 App 外层(批 2b 结构调整后包住 provider+壳,职责不变)
201+ < div style = { { height : "100vh" , display : "flex" , flexDirection : "column" , fontFamily : "system-ui, sans-serif" } } >
224202 { /* v0.16.1:浏览器痕迹去除——原生右键菜单抑制 + 文本输入应用内右键小菜单 */ }
225203 < BrowserChrome />
226204 { /* 顶部导航 */ }
@@ -270,30 +248,38 @@ function App() {
270248 >
271249 🤖 对话面板
272250 </ button >
273- { /* 全局采集徽标(ADR-007):切页/最小化后仍可见采集状态;2026-08 A1 暂停态 */ }
274- { capturing && (
251+ { /* 全局采集徽标(ADR-007):切页/最小化后仍可见采集状态。
252+ 批 2b:paused → pausedReason 三态文案(manual/media/foreground 同源,
253+ 与右栏/浮窗一致);recovering 与 paused 组合文案保持原语义 */ }
254+ { capture . active && (
275255 < span
276256 style = { {
277257 marginLeft : "auto" ,
278258 fontSize : 12 ,
279259 fontWeight : 600 ,
280- color : paused ? "#b45309" : recovering ? "#b45309" : "#0d9488" ,
281- background : paused ? "#fffbeb" : recovering ? "#fffbeb" : "#f0fdfa" ,
282- border : `1px solid ${ paused ? "#f59e0b" : recovering ? "#f59e0b" : "#14b8a6" } ` ,
260+ color : capture . pausedReason || capture . recovering ? "#b45309" : "#0d9488" ,
261+ background : capture . pausedReason || capture . recovering ? "#fffbeb" : "#f0fdfa" ,
262+ border : `1px solid ${ capture . pausedReason || capture . recovering ? "#f59e0b" : "#14b8a6" } ` ,
283263 borderRadius : 12 ,
284264 padding : "3px 10px" ,
285265 } }
286266 >
287267 { /* 审查修复(观察 2026-08-29-2):恢复态文案区分"暂停挂起"——
288268 暂停期重连风暴曾显示"采集中/恢复中"误导,现在明确"暂停中"语义 */ }
289- { recovering ? ( paused ? "⏸ 暂停挂起(重连中)" : "⚠️ 采集恢复中" ) : paused ? "⏸ 已暂停" : "🎙 采集中" }
269+ { capture . recovering
270+ ? capture . pausedReason
271+ ? "⏸ 暂停挂起(重连中)"
272+ : "⚠️ 采集恢复中"
273+ : capture . pausedReason
274+ ? pauseReasonLabel ( capture . pausedReason )
275+ : "🎙 采集中" }
290276 </ span >
291277 ) }
292278 { /* v0.8.0 F2:AI 任务完成通知(全局 toast——跨页面可见) */ }
293279 { aiToast && (
294280 < span
295281 style = { {
296- marginLeft : capturing ? 8 : "auto" ,
282+ marginLeft : capture . active ? 8 : "auto" ,
297283 fontSize : 12 ,
298284 fontWeight : 500 ,
299285 color : aiToast . kind === "ok" ? "#047857" : "#b91c1c" ,
@@ -424,7 +410,6 @@ function App() {
424410 } }
425411 />
426412 </ div >
427- </ AppErrorBoundary >
428413 ) ;
429414}
430415
0 commit comments