Skip to content

Commit b8ff1ef

Browse files
committed
refactor(ui): 拆 ClassroomPage 第 1 步 — 实时捕获卡
1 parent c8e01d9 commit b8ff1ef

4 files changed

Lines changed: 329 additions & 224 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/**
2+
* ClassroomCapturePanel — 课堂助手左栏「实时捕获」卡片(批 0-C2 Task 4 步 1 自
3+
* ClassroomPage.tsx 的 L495–681 整体抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: 纯展示适配器 —— 采集生命周期(active/starting/pending/sessionId/
6+
* pausedReason)与流式模型四态(未就绪缺件 / 下载中带 MB 进度 / 失败
7+
* 可重试 / 检查中)全部由页面以 props 注入;卡内**不再调用**
8+
* useCaptureControl()(裁决 D2:唯一消费点留在页面,防同窗双消费点漂移)。
9+
* @ai-context: 派生展示值随卡下沉:paused · autoPaused(media/foreground 自动暂停
10+
* ——resume 后端 Ok 但物理无变化,按钮禁用 + title 如实提示)·
11+
* autoPauseHint(AUTO_RESUME_HINTS);暂停三态文案见 pausedCardText。
12+
* @ai-context: 边界与副作用 —— 动作(start/stop/pause/resume/浮窗/下载/重试)只回调
13+
* 页面,卡内不 invoke 采集命令;唯一卡内副作用是「⭐ 标记此刻」调
14+
* save_user_screenshot(同效快捷键留在页面 useClassroomShortcuts);
15+
* 「⏹ 停止」后的关浮窗与再预热属页面 stopLive 编排。样式(内联色值 /
16+
* ~650MB 魔数 / 全角标点 / emoji)为现状契约,本批不 token 化、不换 emoji。
17+
*/
18+
import { invoke } from "@tauri-apps/api/core";
19+
import AudioLevelMeter from "./AudioLevelMeter";
20+
import { AUTO_RESUME_HINTS } from "../hooks/liveCaptureState";
21+
import type { CaptureActionKind } from "../hooks/useLiveCaptureControl";
22+
import type { FloatSnapshot } from "../hooks/useFloatWindow";
23+
import type { DownloadProgress, PauseSource, StreamingModelStatus } from "../types";
24+
25+
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
26+
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
27+
28+
/** P3:引擎预热状态(与 Rust PrepareStatus 的 camelCase 契约一致) */
29+
export type PrepareState = "idle" | "loading" | "ready" | "failed";
30+
31+
/** 采集卡暂停状态行文案(按 reason 三态;沿用原横幅语义——媒体暂停含"自动继续"
32+
* 说明,前台切走含"回窗即继续"说明;徽标/右栏/浮窗用短文案 pauseReasonLabel) */
33+
function pausedCardText(reason: PauseSource): string {
34+
switch (reason) {
35+
case "media":
36+
return "⏸ 已随视频暂停——画面/声音恢复即自动继续";
37+
case "foreground":
38+
return "⏸ 已自动暂停(切走)——回到目标窗口即自动继续";
39+
default:
40+
return "⏸ 已暂停(时间轴冻结,恢复后继续)";
41+
}
42+
}
43+
44+
interface Props {
45+
/** 采集中(决定"录制中"徽标 / 状态行 / VU 表 / 按钮组分支) */
46+
active: boolean;
47+
/** 启动过渡态(等待引擎就绪自动开录——不显示采集中控件) */
48+
starting: boolean;
49+
/** 动作防连点(非空时开始/停止/暂停按钮禁用) */
50+
pending: CaptureActionKind | null;
51+
/** 当前会话 id(非空且非过渡态时提示可到「会话」页查看) */
52+
sessionId: number | null;
53+
/** 暂停原因三态(manual/media/foreground;null=未暂停) */
54+
pausedReason: PauseSource | null;
55+
/** 采集期错误行(引擎错误/浮窗切换失败/标记失败) */
56+
liveError: string;
57+
/** 流式 ASR 模型状态(null=检查中;ready=false 时展示缺件与下载入口) */
58+
modelStatus: StreamingModelStatus | null;
59+
modelDownloading: boolean;
60+
modelProgress: DownloadProgress | null;
61+
/** 模型查询/下载错误(TD-016:不再静默,允许重试) */
62+
modelError: string;
63+
/** 引擎预热状态(就绪后点"开始"即录) */
64+
prepareState: PrepareState;
65+
/** 浮窗三态快照(浮窗化 ⇄ 收起 ⇄ 解锁穿透;Rust 单一来源) */
66+
floatSnap: FloatSnapshot;
67+
onStart: () => void;
68+
onStop: () => void;
69+
onPause: () => void;
70+
onResume: () => void;
71+
onToggleFloat: () => void;
72+
onDownloadModel: () => void;
73+
onRetryModelStatus: () => void;
74+
/** 产物/状态行写入(页面唯一 status 状态源) */
75+
onStatus: (message: string) => void;
76+
/** 错误横幅写入(页面唯一 liveError 状态源) */
77+
onLiveError: (message: string) => void;
78+
}
79+
80+
export default function ClassroomCapturePanel({
81+
active,
82+
starting,
83+
pending,
84+
sessionId,
85+
pausedReason,
86+
liveError,
87+
modelStatus,
88+
modelDownloading,
89+
modelProgress,
90+
modelError,
91+
prepareState,
92+
floatSnap,
93+
onStart,
94+
onStop,
95+
onPause,
96+
onResume,
97+
onToggleFloat,
98+
onDownloadModel,
99+
onRetryModelStatus,
100+
onStatus,
101+
onLiveError,
102+
}: Props) {
103+
// ── 采集卡派生展示值(批 2b:暂停 reason 三态语义)──
104+
const paused = pausedReason != null;
105+
/** 自动暂停(media/foreground):resume 无物理作用——按钮禁用 + 标题提示 */
106+
const autoPaused = paused && pausedReason !== "manual";
107+
const autoPauseHint =
108+
pausedReason === "media" || pausedReason === "foreground" ? AUTO_RESUME_HINTS[pausedReason] : undefined;
109+
110+
return (
111+
<div style={panel}>
112+
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 6 }}>
113+
实时捕获{active && <span style={{ color: "#dc2626" }}> ● 录制中</span>}
114+
</div>
115+
{!active && !modelStatus?.ready && (
116+
<div>
117+
{modelStatus ? (
118+
<p style={{ fontSize: 11, color: "#b45309", margin: "0 0 6px" }}>
119+
流式 ASR 模型未就绪(缺 {modelStatus.missing.join(", ")})
120+
</p>
121+
) : (
122+
<p style={{ fontSize: 11, color: "#dc2626", margin: "0 0 6px" }}>
123+
{modelError || "模型状态检查中…"}
124+
</p>
125+
)}
126+
{modelStatus &&
127+
(modelDownloading ? (
128+
<div style={{ fontSize: 11, color: "#374151", marginBottom: 6 }}>
129+
<div>⏳ 正在下载模型(~650MB)…</div>
130+
{modelProgress && (
131+
<div>
132+
{modelProgress.file}:
133+
{((modelProgress.downloadedBytes / 1024 / 1024) | 0)}MB /{" "}
134+
{((modelProgress.totalBytes / 1024 / 1024) | 0)}MB
135+
</div>
136+
)}
137+
</div>
138+
) : (
139+
<button
140+
onClick={() => void onDownloadModel()}
141+
style={{ ...btn, width: "100%", padding: "8px 0", fontWeight: 600, background: "#0d9488", color: "#fff", border: "none", borderRadius: 6, marginBottom: 6 }}
142+
>
143+
⬇ 一键下载并配置模型
144+
</button>
145+
))}
146+
{modelError && <p style={{ fontSize: 11, color: "#dc2626", margin: "0 0 6px" }}>{modelError}</p>}
147+
{!modelStatus && (
148+
<button
149+
onClick={() => void onRetryModelStatus()}
150+
style={{ ...btn, width: "100%", padding: "6px 0", border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff" }}
151+
>
152+
⟳ 重试检查
153+
</button>
154+
)}
155+
</div>
156+
)}
157+
{active && (
158+
// 实时内容(字幕/语音/画面)统一由右侧 LiveActivityPanel 展示,
159+
// 左栏保持精简(状态徽标)——审查观察项修复
160+
// 批 2b:暂停按 pausedReason 三态文案(media 行并入原随播随停
161+
// 横幅语义——单一状态源后横幅/状态行不再双份维护)
162+
<div style={{ fontSize: 11, color: paused ? "#b45309" : "#0d9488", marginBottom: 6 }}>
163+
{paused ? pausedCardText(pausedReason) : "● 正在采集(实时内容见右侧面板)"}
164+
</div>
165+
)}
166+
{/* 2026-08 A2:音频电平条(仅采集中显示;暂停时电平静止) */}
167+
{active && !paused && <AudioLevelMeter />}
168+
{liveError && <p style={{ fontSize: 11, color: "#dc2626", margin: "0 0 6px" }}>{liveError}</p>}
169+
{active ? (
170+
/* 采集中按钮组(2026-08 A1:暂停/继续 + 标记此刻 + 停止)。
171+
批 2b:manual 暂停 → "继续捕获"可点;auto(media/foreground)
172+
暂停 → resume 后端 Ok 但物理无变化(auto 条件仍真)——按钮禁用
173+
+ title 提示,不提供误导性"恢复成功"反馈;pending 期间防连点 */
174+
<div style={{ display: "flex", gap: 6 }}>
175+
<button
176+
onClick={paused ? onResume : onPause}
177+
disabled={pending != null || autoPaused}
178+
title={autoPauseHint}
179+
style={{
180+
...btn,
181+
flex: 1,
182+
padding: "8px 0",
183+
fontWeight: 600,
184+
background: paused ? (pausedReason === "manual" ? "#0d9488" : "#d1d5db") : "#f59e0b",
185+
color: "#fff",
186+
border: "none",
187+
borderRadius: 6,
188+
}}
189+
>
190+
{paused ? (pausedReason === "manual" ? "▶ 继续捕获" : "⏸ 自动暂停中") : "⏸ 暂停"}
191+
</button>
192+
{/* 2026-08 A3:手动标记此刻(最高权重关键图信号;Ctrl+Shift+S 同效) */}
193+
<button
194+
onClick={() => {
195+
void invoke<string>("save_user_screenshot")
196+
.then(() => onStatus("⭐ 已标记此刻画面(关键图候选置顶)"))
197+
.catch((err) => onLiveError(`标记失败: ${err}`));
198+
}}
199+
title="快捷键 Ctrl+Shift+S"
200+
style={{
201+
...btn,
202+
flex: 1,
203+
padding: "8px 0",
204+
fontWeight: 600,
205+
background: "#fff",
206+
color: "#0d9488",
207+
border: "1px solid #99f6e4",
208+
borderRadius: 6,
209+
}}
210+
>
211+
⭐ 标记此刻
212+
</button>
213+
<button
214+
onClick={onStop}
215+
disabled={pending != null}
216+
style={{
217+
...btn,
218+
flex: 1,
219+
padding: "8px 0",
220+
fontWeight: 600,
221+
background: "#dc2626",
222+
color: "#fff",
223+
border: "none",
224+
borderRadius: 6,
225+
}}
226+
>
227+
⏹ 停止
228+
</button>
229+
{/* v0.12.0 M6:浮窗化——采集中全屏看视频时悬浮常显(快捷键 Ctrl+Shift+F)
230+
v0.12.3:三态语义(浮窗化 ⇄ 收起 ⇄ 解锁点击穿透) */}
231+
<button
232+
onClick={onToggleFloat}
233+
title={
234+
floatSnap.open
235+
? floatSnap.locked
236+
? "点击穿透已锁定——点击解锁(快捷键 Ctrl+Shift+F)"
237+
: "收起采集浮窗(快捷键 Ctrl+Shift+F)"
238+
: "采集中全屏看视频时悬浮常显(快捷键 Ctrl+Shift+F)"
239+
}
240+
style={{
241+
...btn,
242+
flex: 1,
243+
padding: "8px 0",
244+
fontWeight: 600,
245+
background: "#fff",
246+
color: floatSnap.locked ? "#dc2626" : "#0d9488",
247+
border: floatSnap.locked ? "1px solid #fecaca" : "1px solid #99f6e4",
248+
borderRadius: 6,
249+
}}
250+
>
251+
{floatSnap.open ? (floatSnap.locked ? "🔓 解锁浮窗" : "🗕 收起浮窗") : "🗕 浮窗化"}
252+
</button>
253+
</div>
254+
) : (
255+
<>
256+
{/* v0.19.2:启动过渡态(等待引擎就绪自动开录——不显示采集中控件) */}
257+
{starting ? (
258+
<p style={{ fontSize: 11, color: "#b45309", margin: "6px 0 0" }}>
259+
⏳ 引擎初始化中…就绪后自动开始(音频与画面同刻启动,请勿重复点击)
260+
</p>
261+
) : (
262+
<>
263+
{/* P3:引擎预热状态提示(就绪后点"开始"即录) */}
264+
{prepareState === "loading" && (
265+
<p style={{ fontSize: 11, color: "#6b7280", margin: "6px 0 0" }}>
266+
⏳ 引擎预热中…(就绪后开始即录)
267+
</p>
268+
)}
269+
{prepareState === "ready" && (
270+
<p style={{ fontSize: 11, color: "#0d9488", margin: "6px 0 0" }}>
271+
✓ 引擎已就绪,开始即录
272+
</p>
273+
)}
274+
</>
275+
)}
276+
<button
277+
onClick={onStart}
278+
disabled={!modelStatus?.ready || starting || pending != null}
279+
style={{
280+
...btn,
281+
width: "100%",
282+
padding: "8px 0",
283+
fontWeight: 600,
284+
background: modelStatus?.ready && !starting && pending == null ? "#0d9488" : "#e5e7eb",
285+
color: modelStatus?.ready && !starting && pending == null ? "#fff" : "#9ca3af",
286+
border: "none",
287+
borderRadius: 6,
288+
}}
289+
>
290+
{starting ? "⏳ 引擎初始化中…" : "▶ 开始实时捕获"}
291+
</button>
292+
</>
293+
)}
294+
{sessionId && !starting && (
295+
<p style={{ fontSize: 11, color: "#6b7280", margin: "6px 0 0" }}>实时捕获中(可到「会话」页查看)</p>
296+
)}
297+
</div>
298+
);
299+
}

0 commit comments

Comments
 (0)