Skip to content

Commit b4f6a97

Browse files
committed
feat(mobile): 接入 Capacitor 移动端支持与课堂移动面板
1 parent 3c2cf41 commit b4f6a97

87 files changed

Lines changed: 10316 additions & 22 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎client/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ lerna-debug.log*
1616
node_modules
1717
dist
1818
dist-ssr
19+
# Capacitor 壳 Web 构建产物(VITE_CAPACITOR=1 输出,与 PWA dist 隔离)
20+
dist-capacitor/
1921
# Electron 主进程编译产物目录(electron-builder 打包输出,不提交到仓库)
2022
dist-electron/
2123
*.local

‎client/package-lock.json‎

Lines changed: 69 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client/package.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@
2828
},
2929
"dependencies": {
3030
"@automerge/automerge": "^3.3.2",
31+
"@capacitor/app": "^7.0.0",
32+
"@capacitor/camera": "^7.0.0",
33+
"@capacitor/core": "^7.0.0",
34+
"@capacitor/filesystem": "^7.0.0",
35+
"@capacitor/keyboard": "^7.0.0",
36+
"@capacitor/status-bar": "^7.0.0",
3137
"@dagrejs/dagre": "^3.0.0",
3238
"@modelcontextprotocol/sdk": "^1.29.0",
3339
"@modelcontextprotocol/server-filesystem": "^2026.7.10",

‎client/src/components/layout/CustomTitlebar.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
22
import { Minus, Square, Maximize2, X } from 'lucide-react';
33
import { cn } from '@/lib/utils';
44
import { QuotaBadge } from './QuotaBadge';
5+
import { isCapacitor } from '@/lib/platform/platform';
56

67
/**
78
* 自定义标题栏组件属性
@@ -50,6 +51,10 @@ export function CustomTitlebar({ className }: CustomTitlebarProps) {
5051
window.electronAPI?.windowClose();
5152
}, []);
5253

54+
// 移动端 Capacitor 壳无窗口控制:整个标题栏不渲染(系统状态栏接管),
55+
// 桌面端与 Web/PWA 路径保持不变
56+
if (isCapacitor()) return null;
57+
5358
return (
5459
<div
5560
className={cn(
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* MobileAssistantPanel — 课堂助手移动端面板(视频知识笔记提取)
3+
*
4+
* @ai-context: 仅 Capacitor 壳内渲染(isCapacitor 门控,ClassroomPage 装配)。
5+
* 两条采集路径:① 导入视频 → 抽音频 → 本地/云端转写 → 知识笔记;
6+
* ② 录屏采集(小布式边看边录)→ 麦克风流式采集 → 本地 ASR 实时字幕 →
7+
* 停止后结构化。完成后提供:转写预览、基于转写的课堂问答、一键生成闪卡。
8+
* @ai-context EN: mobile-only classroom panel — video import and
9+
* screen-recording pipelines, then transcript QA and one-click flashcards.
10+
*/
11+
import { useCallback, useRef, useState } from 'react';
12+
import { useToast } from '@/components/ui';
13+
import {
14+
Upload, MonitorPlay, Square, Loader2, AlertCircle, CheckCircle2, MessageCircleQuestion, Layers,
15+
} from 'lucide-react';
16+
import { cn } from '@/lib/utils';
17+
import {
18+
runVideoImportPipeline,
19+
runRecordingFlow,
20+
structureAndSave,
21+
generateFlashcardsFromTranscript,
22+
askSessionQuestion,
23+
type PipelineProgress,
24+
} from '../lib/mobilePipeline';
25+
26+
const STAGE_LABELS: Record<PipelineProgress['stage'], string> = {
27+
idle: '待命',
28+
picking: '选择视频',
29+
extracting: '抽取音频',
30+
transcribing: '语音转写',
31+
structuring: '生成笔记',
32+
recording: '录制中',
33+
done: '完成',
34+
error: '失败',
35+
};
36+
37+
export function MobileAssistantPanel() {
38+
const { toast } = useToast();
39+
const [progress, setProgress] = useState<PipelineProgress>({ stage: 'idle', message: '选择一种采集方式开始' });
40+
const [subtitle, setSubtitle] = useState('');
41+
const [transcript, setTranscript] = useState('');
42+
const [busy, setBusy] = useState(false);
43+
const [recording, setRecording] = useState(false);
44+
const [qaQuestion, setQaQuestion] = useState('');
45+
const [qaAnswer, setQaAnswer] = useState('');
46+
const [qaLoading, setQaLoading] = useState(false);
47+
const [cardsLoading, setCardsLoading] = useState(false);
48+
const recordingRef = useRef<Awaited<ReturnType<typeof runRecordingFlow>> | null>(null);
49+
50+
const fail = useCallback((e: unknown) => {
51+
// 用户取消选择/授权不是错误:回到待命态
52+
if (e instanceof Error && e.message === '已取消') {
53+
setProgress({ stage: 'idle', message: '选择一种采集方式开始' });
54+
return;
55+
}
56+
setProgress({ stage: 'error', message: e instanceof Error ? e.message : '处理失败,请重试' });
57+
}, []);
58+
59+
/** ① 导入视频 → 全流程流水线 */
60+
const handleImportVideo = useCallback(async () => {
61+
if (busy) return;
62+
setBusy(true);
63+
setSubtitle('');
64+
setQaAnswer('');
65+
try {
66+
await runVideoImportPipeline(setProgress, setTranscript);
67+
setProgress({ stage: 'done', message: '视频知识笔记已生成,可在笔记中查看' });
68+
toast({ type: 'success', message: '视频知识笔记已生成' });
69+
} catch (e) {
70+
fail(e);
71+
} finally {
72+
setBusy(false);
73+
}
74+
}, [busy, fail, toast]);
75+
76+
/** ② 开始录屏采集(画面 + 麦克风实时字幕) */
77+
const handleStartRecording = useCallback(async () => {
78+
if (busy) return;
79+
setBusy(true);
80+
setSubtitle('');
81+
setQaAnswer('');
82+
setProgress({ stage: 'recording', message: '正在发起录屏授权…' });
83+
try {
84+
const handle = await runRecordingFlow(setProgress, setSubtitle);
85+
recordingRef.current = handle;
86+
setRecording(true);
87+
} catch (e) {
88+
fail(e);
89+
} finally {
90+
setBusy(false);
91+
}
92+
}, [busy, fail]);
93+
94+
/** 停止录屏 → 转写收尾 → 结构化 */
95+
const handleStopRecording = useCallback(async () => {
96+
const handle = recordingRef.current;
97+
if (!handle || busy) return;
98+
setBusy(true);
99+
try {
100+
setProgress({ stage: 'transcribing', message: '整理转写…' });
101+
const { text } = await handle.stop();
102+
setRecording(false);
103+
recordingRef.current = null;
104+
if (text.trim()) {
105+
setTranscript(text);
106+
setProgress({ stage: 'structuring', message: '生成知识笔记…' });
107+
await structureAndSave(text, 0, 'video');
108+
setProgress({ stage: 'done', message: '录屏知识笔记已生成,可在笔记中查看' });
109+
toast({ type: 'success', message: '录屏笔记已生成' });
110+
} else {
111+
setProgress({
112+
stage: 'error',
113+
message: '未识别到语音内容。录屏可能无法收录视频原声,建议靠近麦克风讲解或改用「导入视频」',
114+
});
115+
}
116+
} catch (e) {
117+
setRecording(false);
118+
recordingRef.current = null;
119+
fail(e);
120+
} finally {
121+
setBusy(false);
122+
}
123+
}, [busy, fail, toast]);
124+
125+
/** 基于转写的课堂问答 */
126+
const handleAsk = useCallback(async () => {
127+
const q = qaQuestion.trim();
128+
if (!q || !transcript.trim() || qaLoading) return;
129+
setQaLoading(true);
130+
setQaAnswer('');
131+
try {
132+
const { answer } = await askSessionQuestion(transcript, q);
133+
setQaAnswer(answer);
134+
} catch (e) {
135+
setQaAnswer(e instanceof Error ? e.message : '问答失败,请检查网关连通性');
136+
} finally {
137+
setQaLoading(false);
138+
}
139+
}, [qaQuestion, transcript, qaLoading]);
140+
141+
/** 一键生成闪卡 */
142+
const handleGenerateCards = useCallback(async () => {
143+
if (!transcript.trim() || cardsLoading) return;
144+
setCardsLoading(true);
145+
try {
146+
const count = await generateFlashcardsFromTranscript(transcript);
147+
toast({ type: 'success', message: `已生成 ${count} 张闪卡(新牌组)` });
148+
} catch (e) {
149+
toast({ type: 'error', message: e instanceof Error ? e.message : '闪卡生成失败' });
150+
} finally {
151+
setCardsLoading(false);
152+
}
153+
}, [transcript, cardsLoading, toast]);
154+
155+
const inProgress = busy || recording;
156+
const isError = progress.stage === 'error';
157+
const hasTranscript = transcript.trim().length > 0;
158+
159+
return (
160+
<div className="space-y-2.5">
161+
{/* 状态横幅 */}
162+
<div
163+
className={cn(
164+
'px-3 py-2 rounded-kb-lg text-b3 border',
165+
isError
166+
? 'bg-semantic-error/5 border-semantic-error/15 text-semantic-error'
167+
: progress.stage === 'done'
168+
? 'bg-brand-500/5 border-brand-400/20 text-brand-600'
169+
: 'bg-bg-elevated/60 border-border/30 text-text-secondary',
170+
)}
171+
>
172+
<div className="flex items-center gap-1.5">
173+
{isError ? (
174+
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
175+
) : progress.stage === 'done' ? (
176+
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
177+
) : inProgress ? (
178+
<Loader2 className="w-3.5 h-3.5 animate-spin flex-shrink-0" />
179+
) : null}
180+
<span className="font-medium">{STAGE_LABELS[progress.stage]}:</span>
181+
<span className="truncate">{progress.message}</span>
182+
</div>
183+
{progress.total != null && progress.current != null && (
184+
<div className="mt-1.5 h-1 rounded-full bg-bg-tertiary overflow-hidden">
185+
<div
186+
className="h-full bg-brand-500 transition-all"
187+
style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }}
188+
/>
189+
</div>
190+
)}
191+
</div>
192+
193+
{/* 实时字幕(录屏期间) */}
194+
{recording && (
195+
<div className="px-3 py-2 rounded-kb-lg bg-black/30 border border-white/10 text-b3 text-text-primary min-h-[3rem] max-h-24 overflow-y-auto">
196+
<p className="text-[11px] text-text-tertiary mb-1">实时字幕(本地 ASR)</p>
197+
<p className="leading-relaxed">{subtitle || '等待语音…'}</p>
198+
</div>
199+
)}
200+
201+
{/* 操作按钮 */}
202+
<button
203+
onClick={handleImportVideo}
204+
disabled={inProgress}
205+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg border border-dashed border-border/60 text-text-secondary hover:text-brand-600 hover:border-brand-400 active:scale-[0.98] transition-all text-b3 disabled:opacity-60"
206+
>
207+
<Upload className="w-4 h-4" /> 导入视频生成笔记(≤60 分钟)
208+
</button>
209+
210+
{recording ? (
211+
<button
212+
onClick={handleStopRecording}
213+
disabled={busy}
214+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg bg-semantic-error/10 border border-semantic-error/30 text-semantic-error active:scale-[0.98] transition-all text-b3 disabled:opacity-60"
215+
>
216+
<Square className="w-4 h-4" /> 停止录屏并生成笔记
217+
</button>
218+
) : (
219+
<button
220+
onClick={handleStartRecording}
221+
disabled={inProgress}
222+
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-kb-lg bg-brand-600/90 text-white hover:bg-brand-600 active:scale-[0.98] transition-all text-b3 disabled:opacity-60"
223+
>
224+
<MonitorPlay className="w-4 h-4" /> 录屏采集(边看网课边记)
225+
</button>
226+
)}
227+
228+
{/* 转写后增强:问答 + 闪卡 */}
229+
{hasTranscript && !recording && (
230+
<div className="space-y-2 pt-1 border-t border-border/20">
231+
<p className="text-[11px] text-text-tertiary">转写完成({transcript.length} 字),可继续:</p>
232+
<div className="flex gap-2">
233+
<input
234+
value={qaQuestion}
235+
onChange={(e) => setQaQuestion(e.target.value)}
236+
onKeyDown={(e) => e.key === 'Enter' && handleAsk()}
237+
placeholder="向这节课提问,如:核心概念是什么?"
238+
className="flex-1 px-2.5 py-1.5 rounded-kb-md border border-border/50 text-[12px] bg-bg-elevated/50 focus:outline-none focus:border-brand-400"
239+
/>
240+
<button
241+
onClick={handleAsk}
242+
disabled={qaLoading || !qaQuestion.trim()}
243+
className="flex items-center gap-1 px-3 py-1.5 rounded-kb-md bg-brand-500/10 border border-brand-400/30 text-brand-600 text-[12px] active:scale-95 transition-all disabled:opacity-50"
244+
>
245+
{qaLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MessageCircleQuestion className="w-3.5 h-3.5" />}
246+
提问
247+
</button>
248+
</div>
249+
{qaAnswer && (
250+
<div className="px-3 py-2 rounded-kb-lg bg-bg-elevated/60 border border-border/30 text-b3 leading-relaxed max-h-40 overflow-y-auto">
251+
{qaAnswer}
252+
</div>
253+
)}
254+
<button
255+
onClick={handleGenerateCards}
256+
disabled={cardsLoading}
257+
className="w-full flex items-center justify-center gap-2 py-2 rounded-kb-lg border border-dashed border-border/60 text-text-secondary hover:text-brand-600 hover:border-brand-400 active:scale-[0.98] transition-all text-b3 disabled:opacity-60"
258+
>
259+
{cardsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Layers className="w-4 h-4" />}
260+
一键生成间隔重复闪卡
261+
</button>
262+
</div>
263+
)}
264+
265+
{isError && (
266+
<p className="text-[11px] text-text-tertiary text-center">
267+
可重试,或检查 AI 网关连通性后再次尝试
268+
</p>
269+
)}
270+
</div>
271+
);
272+
}

0 commit comments

Comments
 (0)