Skip to content

Commit 3c4867f

Browse files
committed
feat(live-session): 采集控制单一状态源接入三窗(徽标/课堂卡/浮窗/右栏 reason 文案)
1 parent b2a5d90 commit 3c4867f

7 files changed

Lines changed: 305 additions & 323 deletions

File tree

‎app/src/App.tsx‎

Lines changed: 60 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
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
*/
1115
import { useEffect, useRef, useState } from "react";
1216
import { listen } from "@tauri-apps/api/event";
@@ -33,6 +37,9 @@ import CaptureFloatPanel from "./components/CaptureFloatPanel";
3337
import CaptureOverlayPanel from "./components/CaptureOverlayPanel";
3438
// v0.16.1:浏览器痕迹去除(原生右键菜单抑制 + 文本输入应用内右键小菜单)
3539
import BrowserChrome from "./components/BrowserChrome";
40+
// 批 2b:采集控制单一状态源(每窗口单实例 provider + 消费 hook + reason 文案)
41+
import { CaptureStatusProvider, useCaptureControl } from "./hooks/useLiveCaptureControl";
42+
import { pauseReasonLabel } from "./hooks/liveCaptureState";
3643
import type { AiTaskState } from "./types";
3744

3845
type Page = "classroom" | "sessions" | "notes" | "action" | "chat" | "knowledge" | "goals" | "settings";
@@ -52,20 +59,37 @@ const NAV_ITEMS: { key: Page; label: string }[] = [
5259
];
5360

5461
function 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

‎app/src/components/CaptureFloatPanel.tsx‎

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
*
44
* @ai-context: 采集中全屏看视频时主面板被遮挡——常驻悬浮小窗(alwaysOnTop)
55
* 顶部状态/时长、中部最近转写、底部最近画面要点、控制按钮。
6-
* 与主面板共用 useLiveSessionEvents hook(同一 live:* 数据流)。
6+
* 与主面板共用 useLiveSessionEvents hook(同一 live:* 内容流)。
7+
* @ai-context: 批 2b:本窗是**独立 webview**(?float=1,无法共享主窗 context)——
8+
* 暂停/启停走本窗自己的 CaptureStatusProvider 实例(App float
9+
* 分支包入;useCaptureControl 消费),暂停判定与文案以
10+
* pausedReason 单一来源(不再 phase 字符串推导),动作带 pending
11+
* 防连点;useLiveSessionEvents 只负责内容流阶段展示(职责边界)。
712
* @ai-context: v0.12.3 双形态:面板(360×240 全功能)⇄ 字幕条(360×44 只读
813
* 展示,Esc 切换);点击穿透锁定后只读悬浮(解锁走全局快捷键
914
* Ctrl+Shift+F——ADR-025,v0.12.6 起主窗随浮窗打开而隐藏)。
@@ -19,6 +24,9 @@ import { useEffect } from "react";
1924
import { invoke } from "@tauri-apps/api/core";
2025
import { useLiveSessionEvents } from "../hooks/useLiveSessionEvents";
2126
import { useFloatWindow } from "../hooks/useFloatWindow";
27+
// 批 2b:本窗独立的采集控制实例(provider 由 App ?float=1 分支包入)
28+
import { useCaptureControl } from "../hooks/useLiveCaptureControl";
29+
import { AUTO_RESUME_HINTS, pauseReasonLabel } from "../hooks/liveCaptureState";
2230

2331
function fmtTime(ms: number): string {
2432
const s = Math.floor(ms / 1000);
@@ -52,7 +60,17 @@ export default function CaptureFloatPanel() {
5260
useLiveSessionEvents();
5361
const { snapshot, mode, opacity, setViewMode, updateOpacity, startDrag, toggleLocked, toggleTopmost, backToMain } =
5462
useFloatWindow();
55-
const paused = phase.startsWith("⏸");
63+
// 批 2b:暂停单一来源 pausedReason(须会话活动才有意义——滞后事件/停止后为
64+
// null 时回退内容流 phase;旧实现 phase.startsWith("⏸") 推导已废弃)
65+
const capture = useCaptureControl();
66+
const pauseReason = capture.active ? capture.pausedReason : null;
67+
const paused = pauseReason != null;
68+
const autoPaused = paused && pauseReason !== "manual";
69+
const statusText = paused ? pauseReasonLabel(pauseReason) ?? phase : phase;
70+
const statusColor = paused ? "#b45309" : "#dc2626";
71+
const statusChar = paused ? "⏸" : (phase === "正在初始化…" ? "…" : phase).slice(0, 1);
72+
const autoHint =
73+
pauseReason === "media" || pauseReason === "foreground" ? AUTO_RESUME_HINTS[pauseReason] : undefined;
5674

5775
// 浮窗窗口 body 默认 8px margin + 100vh 溢出 → 左右透明条 + 最右滚条
5876
// (用户反馈"两侧透明区");浮窗无全局 CSS——此处注入窗口级重置
@@ -65,14 +83,21 @@ export default function CaptureFloatPanel() {
6583
}, []);
6684

6785
const togglePause = () => {
68-
void invoke(paused ? "resume_live_session" : "pause_live_session").catch((e) =>
69-
console.warn("[capture-float] 暂停/继续失败:", e),
70-
);
86+
if (capture.pending) return; // 防连点
87+
void (paused ? capture.resume() : capture.pause()).then((o) => {
88+
// 守卫错已由 hook 自愈(快照重拉);console 保可观测,不吞
89+
if (!o.ok && o.message) console.warn("[capture-float] 暂停/继续被拒(状态已同步):", o.message);
90+
});
7191
};
7292
const stop = () => {
73-
void invoke("stop_live_session")
74-
.catch((e) => console.warn("[capture-float] 停止采集失败:", e))
75-
.then(() => void invoke("close_capture_float").catch(() => undefined));
93+
if (capture.pending) return; // 防连点
94+
void capture.stop().then((o) => {
95+
if (o.ok) {
96+
void invoke("close_capture_float").catch(() => undefined);
97+
} else if (o.message) {
98+
console.warn("[capture-float] 停止采集失败:", o.message);
99+
}
100+
});
76101
};
77102

78103
/** 拖拽只在空白处触发(按钮/滑杆不劫持鼠标) */
@@ -108,8 +133,8 @@ export default function CaptureFloatPanel() {
108133
overflow: "hidden",
109134
}}
110135
>
111-
<span style={{ color: paused ? "#b45309" : "#dc2626", fontWeight: 700, flexShrink: 0 }}>
112-
{(phase === "正在初始化…" ? "…" : phase).slice(0, 1)}
136+
<span style={{ color: statusColor, fontWeight: 700, flexShrink: 0 }}>
137+
{statusChar}
113138
</span>
114139
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
115140
{lastLine}
@@ -156,8 +181,8 @@ export default function CaptureFloatPanel() {
156181
flexShrink: 0,
157182
}}
158183
>
159-
<span style={{ fontWeight: 600, color: paused ? "#b45309" : "#dc2626" }}>
160-
{phase === "正在初始化…" ? "初始化…" : phase}
184+
<span style={{ fontWeight: 600, color: statusColor }}>
185+
{phase === "正在初始化…" ? "初始化…" : statusText}
161186
</span>
162187
<span style={{ color: "#6b7280", fontVariantNumeric: "tabular-nums" }}>⏱ {fmtTime(elapsedMs)}</span>
163188
<span style={{ marginLeft: "auto", color: "#6b7280" }}>{info?.platform ?? ""}</span>
@@ -224,12 +249,23 @@ export default function CaptureFloatPanel() {
224249
<span style={{ color: "#1e40af", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{o.text}</span>
225250
</div>
226251
))}
227-
{/* 控制按钮(暂停/继续 · 停止 · 回主窗——浮窗保留,主窗前置聚焦) */}
252+
{/* 控制按钮(暂停/继续 · 停止 · 回主窗——浮窗保留,主窗前置聚焦)。
253+
批 2b:auto 暂停(media/foreground)下 resume 无物理作用——按钮禁用
254+
+ title 提示,不误导;pending 期间全部禁用防连点 */}
228255
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
229-
<button style={{ ...btn, background: paused ? "#0d9488" : "#f59e0b", color: "#fff", border: "none" }} onClick={togglePause}>
230-
{paused ? "▶ 继续" : "⏸ 暂停"}
256+
<button
257+
style={{ ...btn, background: autoPaused ? "#d1d5db" : paused ? "#0d9488" : "#f59e0b", color: "#fff", border: "none" }}
258+
disabled={capture.pending != null || autoPaused}
259+
title={autoHint}
260+
onClick={togglePause}
261+
>
262+
{paused ? (autoPaused ? "⏸ 自动暂停" : "▶ 继续") : "⏸ 暂停"}
231263
</button>
232-
<button style={{ ...btn, background: "#dc2626", color: "#fff", border: "none" }} onClick={stop}>
264+
<button
265+
style={{ ...btn, background: "#dc2626", color: "#fff", border: "none" }}
266+
disabled={capture.pending != null}
267+
onClick={stop}
268+
>
233269
⏹ 停止
234270
</button>
235271
<button style={{ ...btn, ...iconBtn, border: "1px solid #d1d5db", background: "#fff", color: "#1f2937" }} onClick={backToMain}>

0 commit comments

Comments
 (0)