Skip to content

Commit 0267a27

Browse files
committed
feat: v0.20.1 — 课堂笔记插入、3D 安全合成器与捕获系统优化
课堂模块增强: - 新增 NoteInsertDialog 笔记插入对话框 - 新增 classroom/utils/ 工具集 - useClassroomCapture / ClassroomPage 优化 3D 系统增强: - 新增 SafeEffectComposer 安全效果合成器 - SceneProvider / AuroraDomeWorld / DeepSeaWorld 优化 捕获系统优化: - captureManager / vadMarker / capture index 重构 - CaptureSidebar / useCaptureSession 改进 Electron 主进程: - main.ts / preload.ts 更新 基础设施: - runtimeDetect / apiClient / vite.config 优化 - README 版本徽章更新至 v0.20.1
1 parent 85c60b9 commit 0267a27

20 files changed

Lines changed: 906 additions & 69 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<p align="center"><strong>你的 AI 智能学习伙伴 — 让每一分钟学习都高效有价值</strong></p>
44
<p align="center">
55
<img src="https://img.shields.io/badge/status-beta-green.svg" alt="Status" />
6-
<img src="https://img.shields.io/badge/version-v0.20.0-blue.svg" alt="Version" />
6+
<img src="https://img.shields.io/badge/version-v0.20.1-blue.svg" alt="Version" />
77
<img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License" />
88
</p>
99
</p>

‎client/electron/main.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ app.commandLine.appendSwitch('enable-zero-copy');
3636
app.commandLine.appendSwitch('enable-features', 'WebGLDraftExtensions,SharedArrayBuffer');
3737

3838
// Windows: ANGLE + Direct3D 11
39+
// 注意:值必须是 'd3d11',曾误写为 'gl' 导致 ANGLE 被锁定 OpenGL 后端,
40+
// 引发 WEBGL_lose_context 缺失、上下文异常及渲染卡顿
3941
if (process.platform === 'win32') {
40-
app.commandLine.appendSwitch('use-angle', 'gl');
41-
app.commandLine.appendSwitch('enable-d3d11', '1');
42+
app.commandLine.appendSwitch('use-angle', 'd3d11');
4243
}
4344

4445
// macOS: Metal
@@ -212,11 +213,13 @@ if (!gotTheLock) {
212213

213214
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
214215
const extraConnectSrc = buildExtraConnectSrc();
215-
// 开发环境:允许 unsafe-inline/unsafe-eval(Vite HMR 需要)
216-
// 生产环境:禁止 unsafe-eval,保留 unsafe-inline(Tailwind 运行时需要)
216+
// 开发环境:允许 unsafe-inline/unsafe-eval(Vite HMR 需要),worker-src 需要 blob:(Vite 8 HMR 客户端用 blob: 创建 SharedWorker)
217+
// 生产环境:禁止 unsafe-eval,保留 unsafe-inline(Tailwind 运行时需要);
218+
// script-src 必须保留 'wasm-unsafe-eval',否则 automerge 的 WASM 模块被 CSP 拦截,
219+
// 导致顶层 import 失败、React 无法挂载、应用卡在启动画面(仅放开 WASM 编译,不放开 JS eval)
217220
const csp = isDev
218-
? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:;`
219-
: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`;
221+
? `default-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; worker-src 'self' blob: http://localhost:*; connect-src 'self' http://localhost:* ws://localhost:* https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:;`
222+
: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.supabase.co; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://entropydecrease.com wss://entropydecrease.com${extraConnectSrc}; frame-ancestors 'none';`;
220223

221224
callback({
222225
responseHeaders: {

‎client/electron/preload.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
137137
},
138138
on: (channel: AllowedEventChannel, callback: (...args: unknown[]) => void) => {
139139
if ((ALLOWED_EVENT_CHANNELS as readonly string[]).includes(channel)) {
140-
ipcRenderer.on(channel, (_event, ...args) => callback(...args));
140+
// 保留包装后的 handler 引用,卸载时只移除自己的监听器。
141+
// 注意:不能使用 removeAllListeners(channel)——多个组件会订阅同一
142+
// channel(如 audio_capture_chunk),removeAllListeners 会把其他
143+
// 组件的监听器一并清除,导致音频流静默中断。
144+
const handler = (_event: unknown, ...args: unknown[]) => callback(...args);
145+
ipcRenderer.on(channel, handler);
141146
return () => {
142-
ipcRenderer.removeAllListeners(channel);
147+
ipcRenderer.removeListener(channel, handler);
143148
};
144149
}
145150
console.warn(`[preload] 不允许的事件 channel: ${channel}`);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ export default function AppLayout() {
8282
}
8383
}
8484

85-
// 数字键 1-6 快捷导航(需无修饰键)
85+
// 数字键 0-7 快捷导航(需无修饰键)
8686
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
8787
const moduleKeys: Record<string, string> = {
8888
'1': '/', '2': '/pomodoro', '3': '/notes',
8989
'4': '/flashcards', '5': '/feynman', '6': '/inspiration',
90-
'7': '/classroom',
90+
'7': '/classroom', '0': '/settings',
9191
};
9292
if (moduleKeys[e.key]) {
9393
navigate(moduleKeys[e.key]);
@@ -138,7 +138,7 @@ export default function AppLayout() {
138138
animate={{ opacity: 1, y: 0 }}
139139
className="px-6 py-3 rounded-full bg-black/30 backdrop-blur-xl border border-white/10 text-white/70 text-sm"
140140
>
141-
点击3D物体进入模块 · 按 Esc 返回仪表盘 · 数字键 1-6 快捷跳转
141+
点击3D物体进入模块 · 按 Esc 返回仪表盘 · 数字键 0-7 快捷跳转
142142
</motion.div>
143143
</div>
144144
{/* 浮动返回仪表盘按钮(仅桌面端) */}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* NoteInsertDialog — 课堂笔记插入弹窗
3+
* 分析完成后弹出,让用户选择:追加到已有笔记 / 创建新笔记 / 复制到剪贴板 / 放弃
4+
*/
5+
import { useState, useEffect, useCallback } from 'react';
6+
import {
7+
FilePlus, FilePen, Clipboard, X, Loader2, ChevronDown,
8+
} from 'lucide-react';
9+
import { cn } from '@/lib/utils';
10+
11+
// ================================================================
12+
// Props
13+
// ================================================================
14+
15+
export interface CourseNoteItem {
16+
id: string;
17+
title: string;
18+
content?: string;
19+
updatedAt: string;
20+
}
21+
22+
interface NoteInsertDialogProps {
23+
/** 待插入的笔记内容(Markdown) */
24+
content: string;
25+
/** 课程名称(用于查询已有笔记和生成标题) */
26+
courseName: string;
27+
/** 当天同课程的采集序号 */
28+
sessionSeq: number;
29+
/** 查询同课程已有笔记 */
30+
fetchCourseNotes: (courseName: string) => Promise<CourseNoteItem[]>;
31+
/** 追加到已有笔记 */
32+
appendToNote: (noteId: string, content: string, sessionLabel: string) => Promise<void>;
33+
/** 创建新笔记 */
34+
createCourseNote: (title: string, content: string) => Promise<void>;
35+
/** 完成回调(成功后关闭弹窗) */
36+
onDone: (message: string) => void;
37+
/** 关闭弹窗 */
38+
onClose: () => void;
39+
}
40+
41+
// ================================================================
42+
// 主组件
43+
// ================================================================
44+
45+
export function NoteInsertDialog({
46+
content,
47+
courseName,
48+
sessionSeq,
49+
fetchCourseNotes,
50+
appendToNote,
51+
createCourseNote,
52+
onDone,
53+
onClose,
54+
}: NoteInsertDialogProps) {
55+
const [existingNotes, setExistingNotes] = useState<CourseNoteItem[]>([]);
56+
const [loadingNotes, setLoadingNotes] = useState(true);
57+
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
58+
const [dropdownOpen, setDropdownOpen] = useState(false);
59+
const [busy, setBusy] = useState(false);
60+
61+
const today = new Date().toLocaleDateString('zh-CN');
62+
const sessionLabel = `${today} 第${sessionSeq}次采集`;
63+
const newNoteTitle = courseName
64+
? `${courseName} - ${today} 课堂笔记`
65+
: `${today} 课堂笔记`;
66+
67+
// 加载同课程已有笔记
68+
useEffect(() => {
69+
let cancelled = false;
70+
setLoadingNotes(true);
71+
fetchCourseNotes(courseName)
72+
.then((notes) => {
73+
if (!cancelled) {
74+
setExistingNotes(notes);
75+
if (notes.length > 0) setSelectedNoteId(notes[0].id);
76+
}
77+
})
78+
.finally(() => { if (!cancelled) setLoadingNotes(false); });
79+
return () => { cancelled = true; };
80+
}, [courseName, fetchCourseNotes]);
81+
82+
// 追加到已有笔记
83+
const handleAppend = useCallback(async () => {
84+
if (!selectedNoteId || busy) return;
85+
setBusy(true);
86+
try {
87+
await appendToNote(selectedNoteId, content, sessionLabel);
88+
const note = existingNotes.find((n) => n.id === selectedNoteId);
89+
onDone(`已追加到「${note?.title ?? '笔记'}」`);
90+
} catch (err) {
91+
console.error('[NoteInsertDialog] 追加失败:', err);
92+
onDone('追加失败,请重试');
93+
} finally {
94+
setBusy(false);
95+
}
96+
}, [selectedNoteId, busy, appendToNote, content, sessionLabel, existingNotes, onDone]);
97+
98+
// 创建新笔记
99+
const handleCreate = useCallback(async () => {
100+
if (busy) return;
101+
setBusy(true);
102+
try {
103+
const fullContent = `## ${sessionLabel}\n\n${content}`;
104+
await createCourseNote(newNoteTitle, fullContent);
105+
onDone(`已创建笔记「${newNoteTitle}」`);
106+
} catch (err) {
107+
console.error('[NoteInsertDialog] 创建失败:', err);
108+
onDone('创建笔记失败,请重试');
109+
} finally {
110+
setBusy(false);
111+
}
112+
}, [busy, content, sessionLabel, newNoteTitle, createCourseNote, onDone]);
113+
114+
// 复制到剪贴板
115+
const handleCopy = useCallback(async () => {
116+
await navigator.clipboard.writeText(content);
117+
onDone('已复制到剪贴板');
118+
}, [content, onDone]);
119+
120+
const selectedNote = existingNotes.find((n) => n.id === selectedNoteId);
121+
122+
return (
123+
<div className="fixed inset-0 z-50 flex items-center justify-center">
124+
{/* 遮罩 */}
125+
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
126+
127+
{/* 弹窗主体 */}
128+
<div className="relative w-[420px] max-w-[90vw] rounded-kb-xl bg-bg-elevated border border-border/40 shadow-kb-lg overflow-hidden">
129+
{/* 头部 */}
130+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border/20">
131+
<h3 className="text-b2 font-semibold text-text-primary">保存课堂笔记</h3>
132+
<button onClick={onClose} className="text-text-tertiary hover:text-text-secondary transition-colors">
133+
<X className="w-4 h-4" strokeWidth={1.5} />
134+
</button>
135+
</div>
136+
137+
<div className="px-5 py-4 space-y-4">
138+
{/* 内容预览摘要 */}
139+
<div className="px-3 py-2.5 rounded-kb-md bg-bg-secondary/60 border border-border/20 max-h-24 overflow-y-auto">
140+
<p className="text-b3 text-text-tertiary leading-relaxed line-clamp-3">
141+
{content.slice(0, 200)}{content.length > 200 ? '...' : ''}
142+
</p>
143+
</div>
144+
145+
{/* 选项 1:追加到已有笔记 */}
146+
<div className="space-y-2">
147+
<div className="flex items-center gap-2">
148+
<FilePen className="w-4 h-4 text-brand-500" strokeWidth={1.5} />
149+
<span className="text-b3 font-medium text-text-secondary">追加到已有笔记</span>
150+
</div>
151+
{loadingNotes ? (
152+
<div className="flex items-center gap-2 px-3 py-2 text-b3 text-text-tertiary">
153+
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 正在查找同课程笔记...
154+
</div>
155+
) : existingNotes.length > 0 ? (
156+
<>
157+
{/* 下拉选择 */}
158+
<div className="relative">
159+
<button
160+
onClick={() => setDropdownOpen(!dropdownOpen)}
161+
className="w-full flex items-center justify-between px-3 py-2 rounded-kb-md text-b3 bg-bg-secondary border border-border/30 text-text-primary hover:border-brand-300 transition-colors"
162+
>
163+
<span className="truncate">{selectedNote?.title ?? '选择笔记...'}</span>
164+
<ChevronDown className={cn('w-4 h-4 text-text-tertiary transition-transform', dropdownOpen && 'rotate-180')} strokeWidth={1.5} />
165+
</button>
166+
{dropdownOpen && (
167+
<div className="absolute z-10 mt-1 w-full max-h-36 overflow-y-auto rounded-kb-md bg-bg-elevated border border-border/40 shadow-kb-md">
168+
{existingNotes.map((note) => (
169+
<button
170+
key={note.id}
171+
onClick={() => { setSelectedNoteId(note.id); setDropdownOpen(false); }}
172+
className={cn(
173+
'w-full text-left px-3 py-2 text-b3 transition-colors',
174+
note.id === selectedNoteId ? 'bg-brand-50 text-brand-700' : 'text-text-secondary hover:bg-bg-tertiary',
175+
)}
176+
>
177+
<span className="block truncate">{note.title}</span>
178+
<span className="text-[10px] text-text-tertiary">
179+
更新于 {new Date(note.updatedAt).toLocaleString('zh-CN')}
180+
</span>
181+
</button>
182+
))}
183+
</div>
184+
)}
185+
</div>
186+
<p className="text-[11px] text-text-tertiary">
187+
将在笔记末尾追加「{sessionLabel}」分段
188+
</p>
189+
<button
190+
onClick={handleAppend}
191+
disabled={busy || !selectedNoteId}
192+
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-kb-md text-b3 font-medium bg-brand-600 text-white hover:bg-brand-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
193+
>
194+
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <FilePen className="w-3.5 h-3.5" strokeWidth={1.5} />}
195+
追加到「{selectedNote?.title ? (selectedNote.title.length > 12 ? selectedNote.title.slice(0, 12) + '...' : selectedNote.title) : '...'}」
196+
</button>
197+
</>
198+
) : (
199+
<p className="text-b3 text-text-tertiary px-1">
200+
{courseName ? `未找到「${courseName}」相关笔记` : '未填写课程名,无法查找已有笔记'}
201+
</p>
202+
)}
203+
</div>
204+
205+
{/* 分隔线 */}
206+
<div className="border-t border-border/20" />
207+
208+
{/* 选项 2:创建新笔记 */}
209+
<div className="space-y-2">
210+
<div className="flex items-center gap-2">
211+
<FilePlus className="w-4 h-4 text-emerald-500" strokeWidth={1.5} />
212+
<span className="text-b3 font-medium text-text-secondary">创建新笔记</span>
213+
</div>
214+
<p className="text-[11px] text-text-tertiary px-1">标题:{newNoteTitle}</p>
215+
<button
216+
onClick={handleCreate}
217+
disabled={busy}
218+
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-kb-md text-b3 font-medium bg-emerald-600/10 text-emerald-600 hover:bg-emerald-600/20 active:scale-[0.98] transition-all disabled:opacity-50"
219+
>
220+
<FilePlus className="w-3.5 h-3.5" strokeWidth={1.5} />
221+
创建新笔记
222+
</button>
223+
</div>
224+
225+
{/* 分隔线 */}
226+
<div className="border-t border-border/20" />
227+
228+
{/* 选项 3:复制到剪贴板 */}
229+
<button
230+
onClick={handleCopy}
231+
disabled={busy}
232+
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-kb-md text-b3 font-medium bg-bg-secondary text-text-secondary hover:bg-bg-tertiary active:scale-[0.98] transition-all disabled:opacity-50"
233+
>
234+
<Clipboard className="w-3.5 h-3.5" strokeWidth={1.5} />
235+
仅复制到剪贴板
236+
</button>
237+
</div>
238+
239+
{/* 底部:放弃 */}
240+
<div className="px-5 py-3 border-t border-border/20 bg-bg-secondary/50 flex justify-end">
241+
<button
242+
onClick={onClose}
243+
className="px-3 py-1.5 rounded-kb-md text-b3 font-medium text-text-tertiary hover:text-text-secondary transition-colors"
244+
>
245+
放弃
246+
</button>
247+
</div>
248+
</div>
249+
</div>
250+
);
251+
}
252+
253+
export default NoteInsertDialog;

0 commit comments

Comments
 (0)