Skip to content

Commit a734883

Browse files
committed
perf(client): 性能优化第二批次——笔记投影/课堂内存/订阅治理/3D 算法优化
- P0-4: 搜索索引 5s 防抖+图片节点跳过分词;wordCount 剥离 base64;写库 requestIdleCallback 调度+文档大小自适应窗口 - P0-5: StudySessionPage calculateIntervals useMemo(渲染期 4 次 sm2→0) - P0-6: 课堂内存补漏——resetForStart 清空 smartBundle/增量缓冲;转写失败段 FIFO 上限 30 段(36MB 有界);分析/合并完成统一释放失败段 audioBase64 - P1-1: 笔记列表投影+惰性全文——content 解密后仅保留 300 字符纯文本预览(剥离 base64),全文按需 getById;编辑页/卡片预览/AI 操作/导出/复制/EchoDiscovery 等 15+ 消费点适配 - P1-3: 关闭无投射物的 PCFSoftShadowMap;InstancedMesh 与 StarDust shader 化审计结论记录(放弃及原因) - P1-4: 静谧档 overview 空闲 10s 降 30fps(demand+invalidate 节流,交互即恢复) - P1-5: NotesPage/FlashcardsPage/DeckDetailPage/FeynmanPage/InspirationPage/useFeynmanSession 整 store 订阅拆分 - P1-6: prefetch 错峰——7 个高频页面 chunk 每 300ms 拉取一个 - P2: wavEncoder 分块 base64(耗时降 3-5 倍);MemoryManager 升级泄漏看门狗(heap 趋势+异常跳跃告警) - 清理: PomodoroSettingsPage 未使用 aiRecommendedDuration 订阅;NotesPage 死代码 allNotes(确认被 useConceptConflict 使用后保留) 验证: lint 0 errors/1041 tests 通过/tsc 双端 0 错误/build 成功;浏览器冒烟验证番茄钟交互状态机与笔记投影-惰性加载链路正常
1 parent 2f96df2 commit a734883

23 files changed

Lines changed: 549 additions & 145 deletions

‎client/src/features/classroom/hooks/useClassroomAnalysis.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ export function useClassroomAnalysis({
6464
sessionId: captureSessionIdRef?.current ?? undefined,
6565
});
6666
setAnalysisResult(withOralCleanup(result));
67-
// 全量分析完成,释放所有 keyframe imageBase64 内存
67+
// 全量分析完成,释放所有 keyframe imageBase64 内存;
68+
// P0-6:转写失败段的 audioBase64(回退补转写窗口已过)一并剥离
6869
setSmartBundle((prev) => ({
6970
...prev,
7071
keyframes: (prev.keyframes ?? []).map((kf) => ({ ...kf, imageBase64: '' })),
72+
audioSegments: (prev.audioSegments ?? []).map((s) => (s.audioBase64 ? { ...s, audioBase64: '' } : s)),
7173
}));
7274
} catch (err) {
7375
setAnalysisError(classifyAnalysisError(err));
@@ -126,6 +128,12 @@ export function useClassroomAnalysis({
126128
});
127129
} finally {
128130
setIsAnalyzing(false);
131+
// P0-6 会话结束释放:合并/本地拼接完成后失败段 audioBase64 不再需要
132+
// (回退补转写窗口已过),统一剥离防长课堂无界累积
133+
setSmartBundle((prev) => ({
134+
...prev,
135+
audioSegments: (prev.audioSegments ?? []).map((s) => (s.audioBase64 ? { ...s, audioBase64: '' } : s)),
136+
}));
129137
}
130138
}, [language, onWarn, captureSessionIdRef]);
131139

‎client/src/features/classroom/hooks/useClassroomCapture.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,13 @@ export function useClassroomCapture() {
197197
events.setLiveTranscripts([]);
198198
setSelectedIds(new Set());
199199
setAutoAnchors([]);
200+
// P0-6 内存护栏:上一轮会话的 smartBundle(关键帧/音频段/时间线,含
201+
// 未释放的 base64)与增量分析缓冲必须清空,否则长会话切换无界累积;
202+
// pendingKeyframesRef 等 ref 由 finalizeSmartSession 同步收割,此处双保险
203+
events.setSmartBundle({});
204+
events.pendingKeyframesRef.current = [];
205+
events.partialNotesRef.current = [];
206+
events.isPartialAnalyzingRef.current = false;
200207
// eslint-disable-next-line react-hooks/exhaustive-deps -- events setter 引用稳定
201208
}, []);
202209

‎client/src/features/classroom/hooks/useClassroomEvents.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ const INCREMENTAL_BATCH_SIZE = 5;
4242
const MAX_LIVE_TRANSCRIPTS = 200;
4343
/** 时间线条目上限(FIFO,防止长课堂无界增长) */
4444
const MAX_TIMELINE_ENTRIES = 500;
45+
/** P0-6 仍持 audioBase64 的转写失败段上限(FIFO):单段 ~1.2MB,
46+
* ASR 持续失败时长课堂也须有界(30 × 1.2MB ≈ 36MB 峰值) */
47+
const MAX_FAILED_AUDIO_SEGMENTS = 30;
4548

4649
export interface LiveTranscript {
4750
id: string;
@@ -279,6 +282,22 @@ export function useClassroomEvents({
279282
if (asr.markFailure()) {
280283
onNotify('error', 'ASR 服务连续失败,语音转写可能不可用,请检查网络或 AI 网关');
281284
}
285+
// P0-6 失败段内存护栏:转写失败的段保留 audioBase64 供回退补转写,
286+
// 但长课堂 ASR 持续失败时无界累积——超上限释放最旧失败段(仅剥离
287+
// base64 字段,保留段结构供全量分析消费 audioText 占位)
288+
setSmartBundle((prev) => {
289+
const segs = prev.audioSegments ?? [];
290+
const failedIdx: number[] = [];
291+
for (let i = 0; i < segs.length; i++) {
292+
if (segs[i].audioBase64) failedIdx.push(i);
293+
}
294+
if (failedIdx.length <= MAX_FAILED_AUDIO_SEGMENTS) return prev;
295+
const drop = new Set(failedIdx.slice(0, failedIdx.length - MAX_FAILED_AUDIO_SEGMENTS));
296+
return {
297+
...prev,
298+
audioSegments: segs.map((s, i) => (drop.has(i) ? { ...s, audioBase64: '' } : s)),
299+
};
300+
});
282301
})
283302
.finally(() => asr.release());
284303
});

‎client/src/features/feynman/hooks/useFeynmanSession.ts‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@ import { useParams } from 'react-router-dom';
1212
import { useToast } from '@/components/ui';
1313
import { useFeynmanStore } from '../store/useFeynmanStore';
1414
import { useFlashcardStore } from '@/features/flashcards/store/useFlashcardStore';
15-
import { useShallow } from 'zustand/react/shallow';
1615

1716
export function useFeynmanSession() {
1817
const { sessionId } = useParams<{ sessionId: string }>();
1918

20-
const {
21-
currentNoteId, isLoading,
22-
loadNote,
23-
setExplanation,
24-
updateNote,
25-
addWeakPoint,
26-
removeWeakPoint,
27-
toggleWeakPointMastered,
28-
setSimplifiedSummary,
29-
advanceStep,
30-
setSelfRating,
31-
completeNote,
32-
convertWeakPointsToFlashcards,
33-
getCurrentView,
34-
} = useFeynmanStore(useShallow(s => s));
19+
// P1-5 细粒度订阅:整 store 订阅会在任意笔记/薄弱点变化时重渲染会话页
20+
const currentNoteId = useFeynmanStore((s) => s.currentNoteId);
21+
const isLoading = useFeynmanStore((s) => s.isLoading);
22+
// 动作(稳定引用)
23+
const loadNote = useFeynmanStore((s) => s.loadNote);
24+
const setExplanation = useFeynmanStore((s) => s.setExplanation);
25+
const updateNote = useFeynmanStore((s) => s.updateNote);
26+
const addWeakPoint = useFeynmanStore((s) => s.addWeakPoint);
27+
const removeWeakPoint = useFeynmanStore((s) => s.removeWeakPoint);
28+
const toggleWeakPointMastered = useFeynmanStore((s) => s.toggleWeakPointMastered);
29+
const setSimplifiedSummary = useFeynmanStore((s) => s.setSimplifiedSummary);
30+
const advanceStep = useFeynmanStore((s) => s.advanceStep);
31+
const setSelfRating = useFeynmanStore((s) => s.setSelfRating);
32+
const completeNote = useFeynmanStore((s) => s.completeNote);
33+
const convertWeakPointsToFlashcards = useFeynmanStore((s) => s.convertWeakPointsToFlashcards);
34+
const getCurrentView = useFeynmanStore((s) => s.getCurrentView);
3535

3636
const { toast } = useToast();
3737

‎client/src/features/feynman/pages/FeynmanPage.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import type { ContextMenuGroup } from '@/components/ui';
1313
import { Plus, BookOpen, Trash2, MessageCircle, Lightbulb, SearchCheck, Network, Swords, Brain, Sparkles } from 'lucide-react';
1414
import { useNavigate } from 'react-router-dom';
1515
import { useFeynmanStore } from '../store/useFeynmanStore';
16-
import { useShallow } from 'zustand/react/shallow';
1716
import { useContextMenu } from '@/lib/contextMenu';
1817
import { useToast } from '@/components/ui';
1918
import { soundPlayer } from '@/lib/audio/SoundPlayer';
@@ -41,7 +40,17 @@ const listVariants = {
4140

4241
export default function FeynmanPage() {
4342
const navigate = useNavigate();
44-
const { notes, weakPoints, isLoading, loadNotes, loadWeakPointsForNotes, createNote, deleteNote, getStats, toggleWeakPointMastered } = useFeynmanStore(useShallow(s => s));
43+
// P1-5 细粒度订阅:整 store 订阅会在任意笔记/薄弱点变化时重渲染整页
44+
const notes = useFeynmanStore((s) => s.notes);
45+
const weakPoints = useFeynmanStore((s) => s.weakPoints);
46+
const isLoading = useFeynmanStore((s) => s.isLoading);
47+
// 动作(稳定引用)
48+
const loadNotes = useFeynmanStore((s) => s.loadNotes);
49+
const loadWeakPointsForNotes = useFeynmanStore((s) => s.loadWeakPointsForNotes);
50+
const createNote = useFeynmanStore((s) => s.createNote);
51+
const deleteNote = useFeynmanStore((s) => s.deleteNote);
52+
const getStats = useFeynmanStore((s) => s.getStats);
53+
const toggleWeakPointMastered = useFeynmanStore((s) => s.toggleWeakPointMastered);
4554
const { toast } = useToast();
4655

4756
const {

‎client/src/features/flashcards/pages/DeckDetailPage.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { BookOpen, Plus, Pencil, Trash2, PauseCircle, RotateCcw, Layers } from '
1616
import { cn } from '@/lib/utils';
1717
import { useReducedMotion } from '@/hooks/useReducedMotion';
1818
import { useFlashcardStore } from '../store/useFlashcardStore';
19-
import { useShallow } from 'zustand/react/shallow';
2019
import { useContextMenu } from '@/lib/contextMenu/useContextMenu';
2120
import { useBatchSelection } from '@/hooks/useBatchSelection';
2221
import type { Flashcard } from '@/types/models';
@@ -48,10 +47,18 @@ export default function DeckDetailPage() {
4847
const { deckId } = useParams<{ deckId: string }>();
4948
const navigate = useNavigate();
5049

51-
const {
52-
decks, cards, isLoading,
53-
loadCards, selectDeck, loadDecks, createCard, updateCard, deleteCard, getDeckStats,
54-
} = useFlashcardStore(useShallow(s => s));
50+
// P1-5 细粒度订阅:整 store 订阅会在任意牌组/卡片变化时重渲染整页
51+
const decks = useFlashcardStore((s) => s.decks);
52+
const cards = useFlashcardStore((s) => s.cards);
53+
const isLoading = useFlashcardStore((s) => s.isLoading);
54+
// 动作(稳定引用)
55+
const loadCards = useFlashcardStore((s) => s.loadCards);
56+
const selectDeck = useFlashcardStore((s) => s.selectDeck);
57+
const loadDecks = useFlashcardStore((s) => s.loadDecks);
58+
const createCard = useFlashcardStore((s) => s.createCard);
59+
const updateCard = useFlashcardStore((s) => s.updateCard);
60+
const deleteCard = useFlashcardStore((s) => s.deleteCard);
61+
const getDeckStats = useFlashcardStore((s) => s.getDeckStats);
5562

5663
const deck = decks.find((d) => d.id === deckId);
5764
const { toast } = useToast();

‎client/src/features/flashcards/pages/FlashcardsPage.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { ContextMenu, type ContextMenuGroup } from '@/components/ui/ContextMenu'
99
import { Plus, Layers, Clock, Trash2, Layers3, Upload, BookOpen, Pencil, Share2 } from 'lucide-react';
1010
import { useNavigate } from 'react-router-dom';
1111
import { useFlashcardStore } from '../store/useFlashcardStore';
12-
import { useShallow } from 'zustand/react/shallow';
1312
import { flashcardStore } from '@/lib/storage';
1413
import { importDeck, importDeckNew, importDeckOverwrite, importDeckSkip, importDeckMerge, exportDeck, downloadDeckFile } from '@/lib/storage/exportImport';
1514
import ImportPreviewModal from '../components/ImportPreviewModal';
@@ -110,7 +109,14 @@ const emptyVariants = {
110109

111110
export default function FlashcardsPage() {
112111
const navigate = useNavigate();
113-
const { decks, isLoading, loadDecks, createDeck, renameDeck, deleteDeck } = useFlashcardStore(useShallow(s => s));
112+
// P1-5 细粒度订阅:整 store 订阅会在任意牌组/卡片变化时重渲染整页
113+
const decks = useFlashcardStore((s) => s.decks);
114+
const isLoading = useFlashcardStore((s) => s.isLoading);
115+
// 动作(稳定引用)
116+
const loadDecks = useFlashcardStore((s) => s.loadDecks);
117+
const createDeck = useFlashcardStore((s) => s.createDeck);
118+
const renameDeck = useFlashcardStore((s) => s.renameDeck);
119+
const deleteDeck = useFlashcardStore((s) => s.deleteDeck);
114120

115121
const [allCards, setAllCards] = useState<Flashcard[]>([]);
116122
const [modalOpen, setModalOpen] = useState(false);

‎client/src/features/flashcards/pages/StudySessionPage.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -186,16 +186,16 @@ export default function StudySessionPage() {
186186
const correctRate = completedCount > 0 ? Math.round((correctCount / completedCount) * 100) : 0;
187187
const progress = total > 0 ? Math.round((completedCount / total) * 100) : 0;
188188

189-
const intervals = current
190-
? calculateIntervals({
191-
easeFactor: current.easeFactor,
192-
interval: current.interval,
193-
repetitions: current.repetitions,
194-
})
195-
: null;
196-
const intervalValues = intervals
197-
? [intervals.again, intervals.hard, intervals.good, intervals.easy]
198-
: [1, 1, 1, 1];
189+
// P0-5:calculateIntervals 每次渲染执行 4 次 sm2 计算,memo 化到当前卡变化
190+
const intervalValues = useMemo(() => {
191+
if (!current) return [1, 1, 1, 1];
192+
const intervals = calculateIntervals({
193+
easeFactor: current.easeFactor,
194+
interval: current.interval,
195+
repetitions: current.repetitions,
196+
});
197+
return [intervals.again, intervals.hard, intervals.good, intervals.easy];
198+
}, [current]);
199199

200200
const handleRestart = () => {
201201
setShowSummary(false);

‎client/src/features/inspiration/pages/InspirationPage.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { AIThinkingIndicator } from '@/components/ui/AIThinkingIndicator';
88
import { useToast } from '@/components/ui';
99
import { cn } from '@/lib/utils';
1010
import { useInspirationStore, type InspirationTags } from '../store/inspirationStore';
11-
import { useShallow } from 'zustand/react/shallow';
1211
import { useAITagContent } from '@/lib/ai/useAI';
1312
import { useBatchSort } from '../hooks/useBatchSort';
1413
import { useSortPendingReminder } from '../hooks/useSortPendingReminder';
@@ -35,7 +34,10 @@ import {
3534
// ─────────────────────────────────────────────────────────────
3635

3736
export default function InspirationPage() {
38-
const { items, loadAll, addItem } = useInspirationStore(useShallow(s => s));
37+
// P1-5 细粒度订阅:整 store 订阅会在任意灵感变化时重渲染整页
38+
const items = useInspirationStore((s) => s.items);
39+
const loadAll = useInspirationStore((s) => s.loadAll);
40+
const addItem = useInspirationStore((s) => s.addItem);
3941
const { tagContent, loading: aiLoading } = useAITagContent();
4042
const { toast } = useToast();
4143
const { progress, total, isProcessing: batchProcessing, batchSort } = useBatchSort();

‎client/src/features/notes/components/ConstellationView.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const TEMPLATE_COLORS: Record<string, string> = {
3333
/** 分配笔记到深度区 */
3434
function assignDepth(note: Note): DepthZone {
3535
const age = (Date.now() - new Date(note.updatedAt).getTime()) / 86400000;
36-
if (note.title === '无标题' && !note.content) return 'surface';
36+
// P1-1:投影后无 content,空内容判定改用已维护的 wordCount
37+
if (note.title === '无标题' && !(note.wordCount ?? 0)) return 'surface';
3738
if (age < 1) return 'surface';
3839
if (age < 7) return 'shallow';
3940
if (note.template === 'mindmap' || note.template === 'cornell') return 'deep';
@@ -62,7 +63,8 @@ export function ConstellationView({ notes, onNoteClick }: ConstellationViewProps
6263

6364
for (let i = 0; i < zoneNotes.length; i++) {
6465
const note = zoneNotes[i];
65-
const textLen = (note.content || '').length;
66+
// P1-1:投影后无 content,用已维护的 wordCount 近似篇幅(原语义)
67+
const textLen = note.wordCount ?? 0;
6668
const size = Math.max(6, Math.min(20, textLen / 50));
6769
const x = (i + 0.5) / Math.max(zoneNotes.length, 1) * viewW;
6870
const y = yBase + (Math.random() - 0.5) * zoneHeight * 0.5;

0 commit comments

Comments
 (0)