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