Skip to content

Commit 0dcc26a

Browse files
committed
refactor(notes): NoteEditPage 879→274 拆 17 文件,FreeCanvas 672→268 拆 5 文件
1 parent 16b38ff commit 0dcc26a

24 files changed

Lines changed: 2480 additions & 1551 deletions

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

Lines changed: 268 additions & 672 deletions
Large diffs are not rendered by default.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* 自由画布 AI 智能排版工具栏
3+
* Free-canvas AI layout toolbar
4+
*
5+
* @ai-context: 从 FreeCanvas 拆出。块数 >1 时左下角悬浮:默认态为"AI 整理"
6+
* 按钮(点击进入 tree 模式选择态);选择态展示树形/时间线/聚类三模式按钮与
7+
* 取消。AI 调用与结果落盘(onApplyLayout)由父组件注入;本组件纯展示。
8+
* @ai-context: Extracted from FreeCanvas. Floating bottom-left toolbar shown
9+
* when block count >1: idle state is an "AI 整理" button (click enters tree
10+
* mode-selection state); selection state shows tree/timeline/cluster mode
11+
* buttons plus cancel. The AI call and result commit (onApplyLayout) are
12+
* injected by the parent; this component is purely presentational.
13+
*/
14+
import { Sparkles } from 'lucide-react';
15+
import type { LayoutMode } from '../hooks/useCanvasAILayout';
16+
17+
interface FreeCanvasToolbarProps {
18+
blockCount: number;
19+
aiLayoutLoading: boolean;
20+
aiLayoutMode: LayoutMode | null;
21+
onModeChange: (mode: LayoutMode | null) => void;
22+
onApplyLayout: (mode: LayoutMode) => void;
23+
}
24+
25+
export function FreeCanvasToolbar({
26+
blockCount,
27+
aiLayoutLoading,
28+
aiLayoutMode,
29+
onModeChange,
30+
onApplyLayout,
31+
}: FreeCanvasToolbarProps) {
32+
if (blockCount <= 1) return null;
33+
return (
34+
<div className="absolute bottom-20 left-4 z-10 flex gap-1">
35+
{aiLayoutMode ? (
36+
<div className="flex items-center gap-1 px-2 py-1.5 rounded-kb-md bg-bg-elevated/90 backdrop-blur-sm border border-border/30 shadow-kb-sm">
37+
<span className="text-c1 text-text-tertiary mr-1">AI 排版</span>
38+
{(['tree', 'timeline', 'cluster'] as LayoutMode[]).map((mode) => (
39+
<button
40+
key={mode}
41+
onClick={() => onApplyLayout(mode)}
42+
disabled={aiLayoutLoading}
43+
className="px-2 py-0.5 rounded-kb-sm text-c1 font-medium bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50 transition-colors"
44+
>
45+
{mode === 'tree' ? '树形' : mode === 'timeline' ? '时间线' : '聚类'}
46+
</button>
47+
))}
48+
<button
49+
onClick={() => onModeChange(null)}
50+
className="px-1.5 py-0.5 rounded-kb-sm text-c1 text-text-tertiary hover:text-text-primary transition-colors"
51+
>
52+
取消
53+
</button>
54+
</div>
55+
) : (
56+
<button
57+
onClick={() => onModeChange('tree')}
58+
disabled={aiLayoutLoading}
59+
className="flex items-center gap-1 px-2 py-1.5 rounded-kb-md bg-bg-elevated/90 backdrop-blur-sm border border-border/30 shadow-kb-sm text-c1 text-text-tertiary hover:text-brand-600 hover:border-brand-300 transition-colors disabled:opacity-50"
60+
title="AI 智能排版"
61+
>
62+
<Sparkles className="w-3.5 h-3.5" strokeWidth={1.5} />
63+
{aiLayoutLoading ? '排版中...' : 'AI 整理'}
64+
</button>
65+
)}
66+
</div>
67+
);
68+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* 笔记编辑页 AI 工具行(图片提取 / 信息图 / 滚书背诵 / 阅读模式)
3+
* Note editor AI action bar
4+
*
5+
* @ai-context: 从 NoteEditPage 拆出。仅文本类模板(非 free/cornell/mindmap)
6+
* 显示(守卫条件保留在页面层)。纯展示组件:动作回调全部由页面注入(隐藏
7+
* input 点击、AI 调用、弹窗开关),自身无业务逻辑。
8+
* @ai-context: Extracted from NoteEditPage. Only rendered for text templates
9+
* (guard stays in the page). Pure presentational component: every action
10+
* callback is injected by the page (hidden-input click, AI calls, dialog
11+
* toggles); no business logic here.
12+
*/
13+
import { cn } from '@/lib/utils';
14+
import { ScanText, BarChart3, BookMarked, BookOpen } from 'lucide-react';
15+
16+
interface NoteEditActionBarProps {
17+
onPickVision: () => void;
18+
visionExtracting: boolean;
19+
onGenerateInfographic: () => void;
20+
infographicLoading: boolean;
21+
onOpenRollingRecall: () => void;
22+
readingMode: boolean;
23+
onToggleReadingMode: () => void;
24+
}
25+
26+
export function NoteEditActionBar({
27+
onPickVision,
28+
visionExtracting,
29+
onGenerateInfographic,
30+
infographicLoading,
31+
onOpenRollingRecall,
32+
readingMode,
33+
onToggleReadingMode,
34+
}: NoteEditActionBarProps) {
35+
return (
36+
<div className="flex items-center gap-2 px-4 py-1.5 border-b border-border/20 bg-bg-elevated/20">
37+
<button
38+
onClick={onPickVision}
39+
disabled={visionExtracting}
40+
className="flex items-center gap-1.5 rounded-kb-sm px-2.5 py-1 text-c1 text-text-tertiary transition-colors hover:text-brand-600 hover:bg-brand-500/10 disabled:opacity-50"
41+
title="选择截图/图片,AI 提取文字与公式后插入笔记"
42+
>
43+
<ScanText className="w-3.5 h-3.5" strokeWidth={1.5} />
44+
{visionExtracting ? '提取中…' : 'AI 提取图片文字'}
45+
</button>
46+
<button
47+
onClick={onGenerateInfographic}
48+
disabled={infographicLoading}
49+
className="flex items-center gap-1.5 rounded-kb-sm px-2.5 py-1 text-c1 text-text-tertiary transition-colors hover:text-brand-600 hover:bg-brand-500/10 disabled:opacity-50"
50+
title="AI 将笔记内容转化为结构化信息图"
51+
>
52+
<BarChart3 className="w-3.5 h-3.5" strokeWidth={1.5} />
53+
{infographicLoading ? '生成中…' : '生成信息图'}
54+
</button>
55+
<button
56+
onClick={onOpenRollingRecall}
57+
className="flex items-center gap-1.5 rounded-kb-sm px-2.5 py-1 text-c1 text-text-tertiary transition-colors hover:text-brand-600 hover:bg-brand-500/10"
58+
title="滚书背诵:4 轮渐进式回忆(通读→精读→闭卷→默写)"
59+
>
60+
<BookMarked className="w-3.5 h-3.5" strokeWidth={1.5} />
61+
滚书背诵
62+
</button>
63+
<button
64+
onClick={onToggleReadingMode}
65+
className={cn(
66+
'flex items-center gap-1.5 rounded-kb-sm px-2.5 py-1 text-c1 transition-colors',
67+
readingMode
68+
? 'text-brand-600 bg-brand-500/10'
69+
: 'text-text-tertiary hover:text-brand-600 hover:bg-brand-500/10',
70+
)}
71+
title="阅读模式:自适应排版 + 阅读引导线,专注阅读"
72+
>
73+
<BookOpen className="w-3.5 h-3.5" strokeWidth={1.5} />
74+
{readingMode ? '退出阅读' : '阅读模式'}
75+
</button>
76+
</div>
77+
);
78+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* 未保存更改导航拦截确认框(M19)
3+
* Unsaved-changes navigation blocker dialog (M19)
4+
*
5+
* @ai-context: 从 NoteEditPage 拆出。导航被 useBlocker 拦截时弹确认框,不再
6+
* 静默卡住页面:保存并离开(走页面 handleSaveAndLeave 落盘管线)/ 放弃更改
7+
* (proceed)/ 取消(reset)。保存失败态文案与按钮标签区分展示。纯展示层,
8+
* blocker 状态与保存动作由页面注入。
9+
* @ai-context: Extracted from NoteEditPage. When navigation is intercepted by
10+
* useBlocker, shows a confirm dialog instead of silently blocking: save &
11+
* leave (page handleSaveAndLeave persists first) / discard changes (proceed) /
12+
* cancel (reset). Failed-save wording and button labels differ. Pure
13+
* presentational; the blocker state and save action are injected by the page.
14+
*/
15+
import type { Blocker } from 'react-router-dom';
16+
import { Button } from '@/components/ui';
17+
import type { SaveStatus } from './NoteEditHeader';
18+
19+
interface NoteEditBlockerDialogProps {
20+
blocker: Blocker;
21+
saveStatus: SaveStatus;
22+
onSaveAndLeave: () => void;
23+
}
24+
25+
export function NoteEditBlockerDialog({ blocker, saveStatus, onSaveAndLeave }: NoteEditBlockerDialogProps) {
26+
return (
27+
<div
28+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
29+
role="alertdialog"
30+
aria-modal="true"
31+
aria-labelledby="unsaved-dialog-title"
32+
>
33+
<div className="w-full max-w-sm rounded-2xl border border-border/40 bg-bg-secondary p-5 shadow-xl">
34+
<h3 id="unsaved-dialog-title" className="text-b1 font-semibold text-text-primary mb-2">
35+
{saveStatus === 'failed' ? '保存失败' : '有未保存的更改'}
36+
</h3>
37+
<p className="text-c1 text-text-secondary mb-4">
38+
{saveStatus === 'failed'
39+
? '上次保存失败,离开将丢失未保存的内容'
40+
: '离开前要保存这次编辑吗?'}
41+
</p>
42+
<div className="flex gap-2 justify-end">
43+
<Button variant="primary" size="sm" onClick={onSaveAndLeave}>
44+
{saveStatus === 'failed' ? '仍要离开' : '保存并离开'}
45+
</Button>
46+
<Button variant="secondary" size="sm" onClick={() => { if (blocker.state === 'blocked') blocker.proceed(); }}>
47+
放弃更改
48+
</Button>
49+
<Button variant="ghost" size="sm" onClick={() => { if (blocker.state === 'blocked') blocker.reset(); }}>
50+
取消
51+
</Button>
52+
</div>
53+
</div>
54+
</div>
55+
);
56+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* 笔记编辑页底部弹层集合(AI 摘要 / 内容分层 / 信息图 / 滚书背诵 / 声音锚点)
3+
* Note editor bottom dialog collection
4+
*
5+
* @ai-context: 从 NoteEditPage 拆出。纯展示组合层:接收页面持有的弹层开关、
6+
* ai 摘要状态(useNoteAI 返回值整体注入)、信息图数据与 healthText,渲染各
7+
* Modal 组件;自身无状态。开关状态与 AI 调用仍由页面编排(主文件保留状态编排)。
8+
* 信息图导出 SVG 的下载逻辑随 JSX 一并迁入。
9+
* @ai-context: Extracted from NoteEditPage. Pure presentational composition:
10+
* receives dialog toggles, the useNoteAI return object, infographic data and
11+
* healthText from the page and renders the Modal components; holds no state.
12+
* Dialog toggles and AI calls stay orchestrated by the page. The infographic
13+
* SVG-export download logic moved in with its JSX.
14+
*/
15+
import { AISummaryModal } from './AISummaryModal';
16+
import { ContentTierModal } from './ContentTierModal';
17+
import RollingRecallMode from './RollingRecallMode';
18+
import { Modal } from '@/components/ui/Modal';
19+
import InfographicRenderer from '@/components/InfographicRenderer';
20+
import { SoundAnchorPicker } from '@/features/soundanchor/components/SoundAnchorPicker';
21+
import { Download } from 'lucide-react';
22+
import type { InfographicData } from '@/lib/ai/types';
23+
import type { useNoteAI } from '../hooks/useNoteAI';
24+
25+
interface NoteEditDialogsProps {
26+
/** useNoteAI 返回对象(摘要浮层全部回调/状态整体注入,避免逐项透传) */
27+
ai: ReturnType<typeof useNoteAI>;
28+
noteId: string | null;
29+
noteTitle: string;
30+
healthText: string;
31+
tierOpen: boolean;
32+
onCloseTier: () => void;
33+
infographicOpen: boolean;
34+
onCloseInfographic: () => void;
35+
infographic: InfographicData | null;
36+
infographicLoading: boolean;
37+
infographicError: string | null;
38+
isFallback: boolean;
39+
recallOpen: boolean;
40+
onCloseRecall: () => void;
41+
soundAnchorOpen: boolean;
42+
onCloseSoundAnchor: () => void;
43+
onGoSettings: () => void;
44+
}
45+
46+
export function NoteEditDialogs({
47+
ai,
48+
noteId,
49+
noteTitle,
50+
healthText,
51+
tierOpen,
52+
onCloseTier,
53+
infographicOpen,
54+
onCloseInfographic,
55+
infographic,
56+
infographicLoading,
57+
infographicError,
58+
isFallback,
59+
recallOpen,
60+
onCloseRecall,
61+
soundAnchorOpen,
62+
onCloseSoundAnchor,
63+
onGoSettings,
64+
}: NoteEditDialogsProps) {
65+
return (
66+
<>
67+
{/* AI 摘要结果浮层 */}
68+
{ai.summaryModalOpen && (
69+
<AISummaryModal
70+
data={ai.aiData}
71+
loading={ai.aiLoading}
72+
error={ai.aiError}
73+
needsConfig={ai.aiNeedsConfig}
74+
isStreaming={ai.isStreaming}
75+
streamingText={ai.streamingText}
76+
flashcardLoading={ai.flashcardLoading}
77+
convertedKeys={ai.convertedKeys}
78+
onClose={() => ai.setSummaryModalOpen(false)}
79+
onGoSettings={onGoSettings}
80+
onCopySummary={ai.handleCopySummary}
81+
onGenerateFlashcard={ai.handleGenerateFlashcard}
82+
onGenerateAllFlashcards={ai.handleGenerateAllFlashcards}
83+
onInsertNote={ai.handleInsertNote}
84+
onRegenerate={ai.handleRegenerate}
85+
onExport={ai.handleExport}
86+
onCancelStream={ai.cancelStream}
87+
/>
88+
)}
89+
{/* N5 内容分层弹窗(策略性遗忘标记) */}
90+
<ContentTierModal open={tierOpen} onClose={onCloseTier} noteText={healthText} noteId={noteId} />
91+
92+
{/* 知识信息图弹窗:AI 生成中显示 spinner,失败时 hook 已回退默认图(降级提示见 toast) */}
93+
<Modal
94+
open={infographicOpen}
95+
onClose={onCloseInfographic}
96+
title="知识信息图"
97+
description={infographicError ?? 'AI 将笔记内容可视化为结构化信息图'}
98+
size="lg"
99+
>
100+
{infographicLoading ? (
101+
<div className="flex items-center justify-center py-16">
102+
<div className="flex items-center gap-2 text-text-tertiary">
103+
<div className="w-4 h-4 border-2 border-brand-400/30 border-t-brand-400 rounded-full animate-spin" />
104+
<span className="text-b2">AI 正在生成信息图…</span>
105+
</div>
106+
</div>
107+
) : infographic ? (
108+
<div className="max-h-[60vh] overflow-y-auto">
109+
<InfographicRenderer data={infographic} />
110+
{isFallback && (
111+
<p className="mt-3 text-c1 text-text-tertiary">AI 信息图服务暂不可用,已展示默认信息图。</p>
112+
)}
113+
<div className="flex justify-end mt-3">
114+
<button
115+
onClick={() => {
116+
const svg = document.querySelector('.infographic-renderer svg');
117+
if (!svg) return;
118+
const svgData = new XMLSerializer().serializeToString(svg);
119+
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
120+
const url = URL.createObjectURL(blob);
121+
const a = document.createElement('a');
122+
a.href = url;
123+
a.download = `infographic-${Date.now()}.svg`;
124+
a.click();
125+
URL.revokeObjectURL(url);
126+
}}
127+
className="flex items-center gap-1 px-3 py-1.5 rounded-kb-md text-c1 font-medium text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/40 transition-colors"
128+
>
129+
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
130+
导出 SVG
131+
</button>
132+
</div>
133+
</div>
134+
) : null}
135+
</Modal>
136+
137+
{/* 滚书背诵弹窗:4 轮渐进式回忆 */}
138+
<Modal
139+
open={recallOpen}
140+
onClose={onCloseRecall}
141+
title="滚书背诵"
142+
description="4 轮渐进式回忆:通读标记 → 精读理解 → 闭卷回忆 → 默写输出"
143+
size="lg"
144+
>
145+
<RollingRecallMode
146+
noteContent={healthText}
147+
noteTitle={noteTitle}
148+
onClose={onCloseRecall}
149+
className="max-h-[60vh]"
150+
/>
151+
</Modal>
152+
153+
{/* 3.11 声音记忆锚点选择器:绑定当前笔记概念 */}
154+
<SoundAnchorPicker
155+
open={soundAnchorOpen}
156+
conceptId={noteId ?? 'note'}
157+
conceptTitle={noteTitle || '未命名笔记'}
158+
onClose={onCloseSoundAnchor}
159+
/>
160+
</>
161+
);
162+
}

0 commit comments

Comments
 (0)