Skip to content

Commit 4e9ccf6

Browse files
committed
feat(classroom): 引擎预热前端接入与 ClassroomPage 硬拆收尾
1 parent 6103832 commit 4e9ccf6

4 files changed

Lines changed: 312 additions & 176 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* ClassroomRightPane — 课堂助手右栏内容区(2026-08 审查硬拆:ClassroomPage 641 行
3+
* >600 硬上限,执行豁免登记既有拆分计划;本组件承载右栏全部内容)。
4+
*
5+
* @ai-context: 三态切换:活动态(实时转写流 + 最近画面条 + 画面要点流)/
6+
* 结果态(最近笔记预览)/ 空态(使用说明);配置态顶部为
7+
* 视频类型档案卡(选定窗口后出现)+ 融合完成直达卡片(A4)。
8+
* @ai-context: 纯展示装配(无自有事件监听——活动态由 LiveActivityPanel 自监听),
9+
* 状态全部由父组件注入。
10+
*/
11+
import LiveActivityPanel from "./LiveActivityPanel";
12+
import ProfileDetector from "./ProfileDetector";
13+
import type { Note, ProfileKind, WindowInfo } from "../types";
14+
15+
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
16+
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
17+
18+
interface Props {
19+
/** 活动态(采集中/停止过渡/融合中——右栏由活动面板独占) */
20+
liveActive: boolean;
21+
stopping: boolean;
22+
fusionActive: boolean;
23+
liveSessionId: number | null;
24+
/** 结果态:最近生成的笔记预览 */
25+
lastNote: Note | null;
26+
/** 空态/配置态:目标窗口(档案检测输入) */
27+
selectedWindow: WindowInfo | null;
28+
/** A4:融合完成直达卡片(会话 id;null=不显示) */
29+
fusedSessionId: number | null;
30+
onOpenSessions?: (sessionId: number) => void;
31+
onDismissFused: () => void;
32+
onProfileChange: (kind: ProfileKind) => void;
33+
}
34+
35+
export default function ClassroomRightPane({
36+
liveActive,
37+
stopping,
38+
fusionActive,
39+
liveSessionId,
40+
lastNote,
41+
selectedWindow,
42+
fusedSessionId,
43+
onOpenSessions,
44+
onDismissFused,
45+
onProfileChange,
46+
}: Props) {
47+
return (
48+
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}>
49+
{liveActive || stopping || fusionActive ? (
50+
/* 活动态:实时转写流 + 最近画面条 + 画面要点流 + 状态机(简要:仅最近几条) */
51+
<LiveActivityPanel sessionId={liveSessionId} />
52+
) : (
53+
<div style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
54+
{/* A4:融合完成直达卡片(停止后右侧顶部;一键跳会话页定位) */}
55+
{fusedSessionId && (
56+
<div style={{ padding: "12px 16px 0", maxWidth: 640 }}>
57+
<div style={{ display: "flex", alignItems: "center", gap: 8, border: "1px solid #99f6e4", background: "#f0fdfa", borderRadius: 8, padding: "8px 12px" }}>
58+
<span style={{ fontSize: 12, color: "#0f766e" }}>✅ 融合完成(会话 #{fusedSessionId})</span>
59+
<button
60+
onClick={() => onOpenSessions?.(fusedSessionId)}
61+
style={{ ...btn, marginLeft: "auto", background: "#0d9488", color: "#fff", border: "none", borderRadius: 6 }}
62+
>
63+
查看时间轴 →
64+
</button>
65+
<button
66+
onClick={onDismissFused}
67+
style={{ ...btn, border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff" }}
68+
title="关闭提示"
69+
>
70+
✕
71+
</button>
72+
</div>
73+
</div>
74+
)}
75+
{/* 视频类型档案(v0.5.0 M1:REQ-043 混合检测——自动候选 + 用户确认 + 记忆偏好;
76+
右侧配置区:选定窗口后出现,未选窗口自动隐藏) */}
77+
<div style={{ padding: "12px 16px 0", maxWidth: 640 }}>
78+
<ProfileDetector
79+
windowTitle={selectedWindow?.title ?? null}
80+
onProfileChange={onProfileChange}
81+
/>
82+
</div>
83+
{lastNote ? (
84+
/* 结果态:最近生成的笔记预览 */
85+
<div style={{ padding: 16 }}>
86+
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
87+
<h2 style={{ margin: 0, fontSize: 16 }}>{lastNote.title}</h2>
88+
<span style={{ fontSize: 11, color: "#6b7280" }}>
89+
#{lastNote.id} · {lastNote.source} · {new Date(lastNote.updated_at * 1000).toLocaleString()}
90+
</span>
91+
</div>
92+
<pre
93+
style={{
94+
background: "#f9fafb",
95+
border: "1px solid #e5e7eb",
96+
borderRadius: 8,
97+
padding: 14,
98+
whiteSpace: "pre-wrap",
99+
fontSize: 13,
100+
lineHeight: 1.7,
101+
}}
102+
>
103+
{lastNote.content}
104+
</pre>
105+
<p style={{ fontSize: 12, color: "#6b7280" }}>已保存至笔记,可在「笔记」页继续编辑与检索。</p>
106+
</div>
107+
) : (
108+
/* 空态:当前配置说明书(参考原项目 IdleGuidePanel) */
109+
<div style={{ padding: "16px 24px 24px", maxWidth: 640 }}>
110+
<h2 style={{ fontSize: 18 }}>使用说明</h2>
111+
<ol style={{ fontSize: 13, lineHeight: 2, color: "#374151" }}>
112+
<li><strong>选择目标窗口/进程</strong>:自动推荐疑似网课/视频窗口(B站/播放器/浏览器),也可展开全部手动选择——将作为笔记标题与实时捕获目标;无法采集的窗口(最小化/悬浮层)与站点首页(如 B站首页)已自动过滤</li>
113+
<li><strong>实时捕获</strong>:系统声音 + 屏幕字幕 + 流式转写(Zipformer)边看边记,右侧实时显示转写与画面图片,停止后到「会话」页查看时间轴并可一键转笔记</li>
114+
<li><strong>添加学习素材</strong>:音频文件(WAV,本地 SenseVoice 转写)与图片(本地 PP-OCRv6 识别)</li>
115+
<li><strong>一键提取</strong>:转写 + OCR → 本地拼接为 Markdown 笔记 → 自动保存</li>
116+
</ol>
117+
<div style={{ ...panel, marginTop: 16, fontSize: 12, color: "#6b7280", lineHeight: 1.9 }}>
118+
<div><strong>当前配置</strong></div>
119+
<div>目标窗口:{selectedWindow ? `${selectedWindow.title}(${selectedWindow.processName || "未知进程"})` : "未选择(实时捕获将抓全屏)"}</div>
120+
<div>流式转写:sherpa-onnx Zipformer(实时字幕,需模型就绪)</div>
121+
<div>转写引擎:sherpa-onnx SenseVoice(本地,已就绪)</div>
122+
<div>OCR 引擎:oar-ocr PP-OCRv6(本地,首次使用自动下载模型)</div>
123+
<div>数据主权:全部本地处理,内容不出本机</div>
124+
</div>
125+
</div>
126+
)}
127+
</div>
128+
)}
129+
</div>
130+
);
131+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* MaterialInputPanel — 学习素材(文件)输入与提取按钮(2026-08 审查硬拆:
3+
* ClassroomPage 641 行 >600 硬上限,执行豁免登记既有拆分计划)。
4+
*
5+
* @ai-context: v0.1.0 文件流水线:选音频 WAV(本地 SenseVoice 转写)+ 选图片
6+
* (本地 PP-OCRv6)→ 一键 process_to_note(转写+OCR+拼接+落库);
7+
* 标题取父组件选定窗口标题(无窗口默认名)。
8+
* @ai-context: 状态(素材路径/处理中)自管;产物与状态提示经回调上抛父级。
9+
*/
10+
import { useState } from "react";
11+
import { invoke } from "@tauri-apps/api/core";
12+
import { open } from "@tauri-apps/plugin-dialog";
13+
import type { Note } from "../types";
14+
15+
const btn: React.CSSProperties = { padding: "6px 12px", cursor: "pointer", fontSize: 13 };
16+
const panel: React.CSSProperties = { border: "1px solid #e5e7eb", borderRadius: 8, padding: 12 };
17+
18+
interface Props {
19+
/** 标题来源:选定窗口标题(无窗口 → 默认名) */
20+
windowTitle: string | null;
21+
onNote: (note: Note) => void;
22+
onStatus: (msg: string) => void;
23+
}
24+
25+
export default function MaterialInputPanel({ windowTitle, onNote, onStatus }: Props) {
26+
const [audioPath, setAudioPath] = useState<string | null>(null);
27+
const [imagePaths, setImagePaths] = useState<string[]>([]);
28+
const [processing, setProcessing] = useState(false);
29+
30+
const pickAudio = async () => {
31+
const p = await open({ filters: [{ name: "音频", extensions: ["wav"] }] });
32+
if (typeof p === "string") setAudioPath(p);
33+
};
34+
const pickImages = async () => {
35+
const ps = await open({ multiple: true, filters: [{ name: "图片", extensions: ["png", "jpg", "jpeg", "bmp"] }] });
36+
if (Array.isArray(ps)) setImagePaths(ps as string[]);
37+
};
38+
39+
/** 一键流水线:转写 + OCR + 拼接 → 笔记 */
40+
const runExtract = async () => {
41+
setProcessing(true);
42+
onStatus("流水线处理中(转写 + OCR + 拼接 + 落库)…");
43+
try {
44+
const title = windowTitle ? windowTitle.slice(0, 60) : "课堂记录";
45+
const note = await invoke<Note>("process_to_note", { title, audioPath, imagePaths });
46+
onNote(note);
47+
onStatus(`完成,已保存笔记 #${note.id}`);
48+
setAudioPath(null);
49+
setImagePaths([]);
50+
} catch (e) {
51+
onStatus(`流水线失败: ${e}`);
52+
} finally {
53+
setProcessing(false);
54+
}
55+
};
56+
57+
const hasMaterial = !!audioPath || imagePaths.length > 0;
58+
59+
return (
60+
<>
61+
{/* 素材输入(v0.1.0:文件流水线) */}
62+
<div style={panel}>
63+
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 6 }}>学习素材(文件)</div>
64+
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
65+
<button style={btn} onClick={pickAudio} disabled={processing}>选音频 WAV</button>
66+
<button style={btn} onClick={pickImages} disabled={processing}>选图片(多选)</button>
67+
</div>
68+
{audioPath && <p style={{ fontSize: 11, color: "#374151", marginTop: 6, wordBreak: "break-all" }}>🎵 {audioPath}</p>}
69+
{imagePaths.length > 0 && <p style={{ fontSize: 11, color: "#374151", marginTop: 4 }}>🖼 已选 {imagePaths.length} 张图片</p>}
70+
</div>
71+
72+
{/* 底部启动按钮(参考原项目"开始回声定位"位置) */}
73+
<div style={{ padding: 12, borderTop: "1px solid #e5e7eb" }}>
74+
<button
75+
onClick={runExtract}
76+
disabled={!hasMaterial || processing}
77+
style={{
78+
...btn,
79+
width: "100%",
80+
padding: "10px 0",
81+
fontWeight: 600,
82+
background: hasMaterial && !processing ? "#0d9488" : "#e5e7eb",
83+
color: hasMaterial && !processing ? "#fff" : "#9ca3af",
84+
border: "none",
85+
borderRadius: 8,
86+
}}
87+
>
88+
{processing ? "处理中…" : "🚀 提取为笔记"}
89+
</button>
90+
{!hasMaterial && (
91+
<p style={{ marginTop: 6, textAlign: "center", fontSize: 11, color: "#9ca3af" }}>请先选择音频或图片素材</p>
92+
)}
93+
</div>
94+
</>
95+
);
96+
}

0 commit comments

Comments
 (0)