Skip to content

Commit e492334

Browse files
committed
refactor(ui): 拆 ClassroomPage 第 2 步 — 提示与模型 hook
1 parent 167eef8 commit e492334

3 files changed

Lines changed: 254 additions & 148 deletions

File tree

‎app/src/components/ClassroomCapturePanel.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,13 @@ import { invoke } from "@tauri-apps/api/core";
1919
import AudioLevelMeter from "./AudioLevelMeter";
2020
import { AUTO_RESUME_HINTS } from "../hooks/liveCaptureState";
2121
import type { CaptureActionKind } from "../hooks/useLiveCaptureControl";
22+
import type { PrepareState } from "../hooks/useClassroomHints";
2223
import type { FloatSnapshot } from "../hooks/useFloatWindow";
2324
import type { DownloadProgress, PauseSource, StreamingModelStatus } from "../types";
2425

2526
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
2627
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
2728

28-
/** P3:引擎预热状态(与 Rust PrepareStatus 的 camelCase 契约一致) */
29-
export type PrepareState = "idle" | "loading" | "ready" | "failed";
30-
3129
/** 采集卡暂停状态行文案(按 reason 三态;沿用原横幅语义——媒体暂停含"自动继续"
3230
* 说明,前台切走含"回窗即继续"说明;徽标/右栏/浮窗用短文案 pauseReasonLabel) */
3331
function pausedCardText(reason: PauseSource): string {

‎app/src/hooks/useClassroomHints.ts‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* useClassroomHints — 课堂助手页级提示 / 融合编排 / 流式模型与预热状态(批 0-C2
3+
* Task 4 步 2 自 ClassroomPage.tsx 抽出,纯搬运、行为等价)。
4+
*
5+
* @ai-context: 职责 = 页面级提示横幅与状态行文案的**唯一状态源**:三条采集期横幅
6+
* (ASR 降级 / 目标窗口丢失 / 画面停更)+ 融合三事件对右栏直达卡片与
7+
* 状态行的影响 + 流式模型四态与下载进度 + 引擎预热状态(prepareState)。
8+
* 采集生命周期(active/starting/pending/notice)由页面从
9+
* useCaptureControl 注入——本 hook 不自持、也不再订阅 live:status。
10+
* @ai-context: ★ D1(最高危隐性耦合):`warmUp` 必须由页面**注入**而非本 hook 自建
11+
* ——`model:download-done` 监听里要调它(下载完成即预热,用户无需重进
12+
* 页面);`warmedRef` 的 StrictMode 一次性守卫留在页面与 warmUp 同处。
13+
* `prepareState` 由本 hook 自持并**导出 setter**:页面的 startLive 需按
14+
* start 返回的 prepare 重同步(v0.19.3 审查 LOW-2)。少任何一条都不会
15+
* 编译报错,而是"下载完成即预热"或"点击时重同步"静默消失。
16+
* @ai-context: 副作用 = 12 条 listen(live:error / asr-degraded / asr-recovered /
17+
* window-lost / frame-stalled / frame-recovered / session:fusing /
18+
* fused / fusion-failed / model:download-progress / done / failed)+
19+
* 2 条启动查询(asr_streaming_model_status / model_download_status)+
20+
* 3 条状态镜像 effect(会话结束复位横幅 / starting 收口文案 / 看门狗结论)。
21+
* @ai-context: 边界(本批只搬不改)——`status`/`lastNote` 留页面(D5),本 hook 只经
22+
* `onStatus` 写回,避免状态行双源;三条横幅渲染在 ClassroomBanners、
23+
* 模型卡在 ClassroomCapturePanel;B1/G6/G7 既有缺陷原样保留。
24+
* @ai-context: effect deps 逐字保留原值(`[]` / `[active]` / `[starting, active]` /
25+
* `[notice]`)——`onStatus`(=setStatus)与 `warmUp`(useCallback [])
26+
* 身份恒定,故不入 deps 不改变重订阅时机(与拆分前完全一致)。
27+
*/
28+
import { useCallback, useEffect, useRef, useState } from "react";
29+
import { invoke } from "@tauri-apps/api/core";
30+
import { listen } from "@tauri-apps/api/event";
31+
import type { WatchdogNotice } from "./liveCaptureState";
32+
import type { DownloadProgress, DownloadStatus, StreamingModelStatus } from "../types";
33+
34+
/** P3:引擎预热状态(与 Rust PrepareStatus 的 camelCase 契约一致) */
35+
export type PrepareState = "idle" | "loading" | "ready" | "failed";
36+
37+
interface Options {
38+
/** 采集中(会话结束 → 清空三条页面级提示横幅) */
39+
active: boolean;
40+
/** 启动过渡态(starting 结束且已 active → 覆写状态行"实时捕获已开始") */
41+
starting: boolean;
42+
/** 看门狗结论(start-unconfirmed → 状态行镜像) */
43+
notice: WatchdogNotice | null;
44+
/** 页面持有的预热回调(D1:`model:download-done` 依赖它,不得在本 hook 内重建) */
45+
warmUp: () => void;
46+
/** 状态行写入(D5:status 状态源在页面) */
47+
onStatus: (message: string) => void;
48+
}
49+
50+
export function useClassroomHints({ active, starting, notice, warmUp, onStatus }: Options) {
51+
// ── 实时捕获页面级提示与编排(v0.2.0;采集生命周期状态见 useCaptureControl)──
52+
// 后台融合期(session:fusing 期间,右侧面板显示"融合中")
53+
const [fusionActive, setFusionActive] = useState(false);
54+
// 2026-08 A4:最近融合完成的会话 id(右侧"查看时间轴"直达卡片;切换窗口/新会话时清除)
55+
const [fusedSessionId, setFusedSessionId] = useState<number | null>(null);
56+
const [modelStatus, setModelStatus] = useState<StreamingModelStatus | null>(null);
57+
const [modelDownloading, setModelDownloading] = useState(false);
58+
const [modelProgress, setModelProgress] = useState<DownloadProgress | null>(null);
59+
const [modelError, setModelError] = useState("");
60+
const [liveError, setLiveError] = useState("");
61+
// M7/REQ-042 F5:ASR 降级提示(流式引擎静默失效可见化)
62+
const [asrDegraded, setAsrDegraded] = useState<string | null>(null);
63+
// TD-2026-08-20-I 清偿:目标窗口丢失横幅(采集中画面源不可见提示)
64+
const [windowLost, setWindowLost] = useState(false);
65+
// REQ-281(v0.19.6):画面源停更提示(WGC 长时间无新帧——区别于窗口关闭;
66+
// 恢复帧/停止采集自动清除;null=未停更)
67+
const [frameStalledSecs, setFrameStalledSecs] = useState<number | null>(null);
68+
// P3:引擎预热状态(选窗口阶段后台加载;与 Rust PrepareStatus 契约一致)
69+
const [prepareState, setPrepareState] = useState<PrepareState>("idle");
70+
71+
// 页面级提示与融合编排监听(v0.2.0)。批 2b:live:status / live:paused /
72+
// live:resumed / live:media-* 不再在此订阅——采集生命周期(含随播随停随前台
73+
// 自动暂停)收敛于 CaptureStatusProvider(useCaptureControl),防同窗双监听
74+
useEffect(() => {
75+
const unlisteners: Promise<() => void>[] = [
76+
// 引擎错误横幅(页面级文案;starting 退出由控制 hook 的 engine-error 收敛)
77+
listen<string>("live:error", (e) => setLiveError(e.payload)),
78+
// M7/REQ-042 F5:ASR 降级提示(静默失败可见化;会话停止时清除)
79+
listen<string>("live:asr-degraded", (e) => setAsrDegraded(e.payload)),
80+
// 降级恢复(审查修复):清除降级横幅,避免残留误导
81+
listen("live:asr-recovered", () => setAsrDegraded(null)),
82+
// TD-2026-08-20-I 清偿:目标窗口丢失提示(采集中画面源不可见的唯一信号;
83+
// 会话停止时清除)
84+
listen("live:window-lost", () => setWindowLost(true)),
85+
// REQ-281(v0.19.6):画面停更提示(帧恢复/停止时清除——不复原真实播放器
86+
// 状态,仅提示画面源未出新帧;伴随 WGC 会话自愈重试)
87+
listen<{ silentSecs: number }>("live:frame-stalled", (e) => setFrameStalledSecs(e.payload.silentSecs)),
88+
listen("live:frame-recovered", () => setFrameStalledSecs(null)),
89+
// 后台融合事件(REQ-031):面板显示"融合中",完成后提示并回退
90+
// 2026-08 A4:记录融合完成会话 id(右侧"查看时间轴"直达卡片)。
91+
// (fusing 同时是采集停止的兜底信号——其状态收敛在控制 hook,此处只管
92+
// 面板显示与直达卡片,非重复状态机)
93+
listen<number>("session:fusing", (e) => {
94+
setFusionActive(true);
95+
setFusedSessionId(e.payload);
96+
}),
97+
listen<number>("session:fused", (e) => {
98+
setFusionActive(false);
99+
setFusedSessionId(e.payload);
100+
onStatus("融合完成,可到「会话」页查看融合时间轴");
101+
}),
102+
listen<string>("session:fusion-failed", (e) => {
103+
setFusionActive(false);
104+
// 审查修复:融合失败不得残留"✅ 融合完成"直达卡片(fusing 已预置 id)
105+
setFusedSessionId(null);
106+
onStatus(`融合失败(原始段保留): ${e.payload}`);
107+
}),
108+
// 模型自动下载进度(ADR-003)
109+
listen<DownloadProgress>("model:download-progress", (e) => setModelProgress(e.payload)),
110+
listen<boolean>("model:download-done", () => {
111+
setModelDownloading(false);
112+
setModelProgress(null);
113+
// 审查补充:状态复查失败不能产生 unhandled rejection(与 TD-016 同口径)
114+
void invoke<StreamingModelStatus>("asr_streaming_model_status")
115+
.then(setModelStatus)
116+
.catch((e) => setModelError(`模型状态复查失败: ${e}`));
117+
// P3:模型就绪后立即预热(下载完成即可开始即录,无需重进页面)
118+
warmUp();
119+
}),
120+
// 下载失败:重置"下载中"态并展示错误(审查 M4 修复)
121+
listen<string>("model:download-failed", (e) => {
122+
setModelDownloading(false);
123+
setModelProgress(null);
124+
setModelError(`下载失败: ${e.payload}(可重试或手动放置模型)`);
125+
}),
126+
];
127+
return () => {
128+
unlisteners.forEach((p) => void p.then((fn) => fn()));
129+
};
130+
// eslint-disable-next-line react-hooks/exhaustive-deps
131+
}, []);
132+
133+
// 会话结束侧效复位(原 live:status stopped/failed 分支职责——停止信号现由
134+
// 控制 hook 收敛为 active=false;此处只清页面级提示横幅,不留双状态)
135+
useEffect(() => {
136+
if (!active) {
137+
setAsrDegraded(null);
138+
setWindowLost(false);
139+
setFrameStalledSecs(null);
140+
}
141+
}, [active]);
142+
143+
// recording 收口文案:starting 结束时若已 active → 覆写"实时捕获已开始"
144+
// (engineReady=false 受理路径的"引擎就绪中…"在此被真实开录事件替换;
145+
// 与 v0.19.3 MED-1 的语义一致——starting 迁移不得残留就绪中文案)
146+
const prevStartingRef = useRef(false);
147+
useEffect(() => {
148+
const prev = prevStartingRef.current;
149+
prevStartingRef.current = starting;
150+
if (prev && !starting && active) onStatus("实时捕获已开始");
151+
// eslint-disable-next-line react-hooks/exhaustive-deps
152+
}, [starting, active]);
153+
154+
// 看门狗结论镜像到状态行(unconfirmed=引擎未开录可重试——20s 未确认的单一
155+
// 去向,见 liveCaptureState.WatchdogNotice 注释;"start-restored" 已删无产出
156+
// 分支,审查 P3-6——恢复成功路径由轮询快照逐拍收敛,无独立文案可镜像)
157+
useEffect(() => {
158+
if (notice === "start-unconfirmed") onStatus("启动状态未确认——引擎未开录;可直接重试(就绪即秒开)");
159+
// eslint-disable-next-line react-hooks/exhaustive-deps
160+
}, [notice]);
161+
162+
// 启动时检查流式模型状态 + 下载状态恢复。
163+
// 批 2b:活动会话恢复(live_session_status 拉取)已下沉 CaptureStatusProvider
164+
// 挂载兜底——本页不再独立查询(防双查询/双状态机)
165+
// TD-016:invoke 失败不再静默——展示错误并允许重试(此前按钮永久禁用且无提示)
166+
useEffect(() => {
167+
void invoke<StreamingModelStatus>("asr_streaming_model_status")
168+
.then(setModelStatus)
169+
.catch((e) => setModelError(`模型状态查询失败: ${e}`));
170+
void invoke<DownloadStatus>("model_download_status").then((d) => {
171+
setModelDownloading(d.state === "downloading");
172+
if (d.state === "failed" && d.error) setModelError(d.error);
173+
});
174+
}, []);
175+
176+
/** 重试模型状态检查(TD-016:查询失败后手动恢复按钮可用性) */
177+
const retryModelStatus = async () => {
178+
setModelError("");
179+
try {
180+
const s = await invoke<StreamingModelStatus>("asr_streaming_model_status");
181+
setModelStatus(s);
182+
} catch (e) {
183+
setModelError(`模型状态查询失败: ${e}`);
184+
}
185+
};
186+
187+
/** 一键下载流式 ASR 模型(应用内自动配置) */
188+
const downloadModel = async () => {
189+
setModelError("");
190+
setModelDownloading(true);
191+
try {
192+
await invoke("download_streaming_model");
193+
} catch (e) {
194+
setModelError(`下载启动失败: ${e}`);
195+
setModelDownloading(false);
196+
}
197+
};
198+
199+
/** 「知道了」关闭目标窗口丢失横幅(渲染在 ClassroomBanners) */
200+
const dismissWindowLost = useCallback(() => setWindowLost(false), []);
201+
202+
return {
203+
fusionActive,
204+
fusedSessionId,
205+
setFusedSessionId,
206+
modelStatus,
207+
modelDownloading,
208+
modelProgress,
209+
modelError,
210+
liveError,
211+
setLiveError,
212+
asrDegraded,
213+
windowLost,
214+
dismissWindowLost,
215+
frameStalledSecs,
216+
prepareState,
217+
setPrepareState,
218+
retryModelStatus,
219+
downloadModel,
220+
};
221+
}

0 commit comments

Comments
 (0)