Skip to content

Commit 353b042

Browse files
committed
feat(classroom): 转写批量插入笔记与修正全链路同步
1 parent b4f6a97 commit 353b042

8 files changed

Lines changed: 359 additions & 41 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* NoteInsertDialog 版本切换测试(P0 批量插入方案 B)
3+
*
4+
* @ai-context: 验证"AI 整理版 / 原始转写版"切换开关的显隐与内容生效:
5+
* rawContent 未提供时不显示开关;提供时默认 AI 版、可切原文版,
6+
* 复制/追加/创建均使用当前生效版本。
7+
* English: version toggle (AI-processed vs raw transcript) inside the
8+
* note insert dialog — hidden without rawContent; active content follows
9+
* the selected tab for copy/append/create actions.
10+
*/
11+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
13+
import { NoteInsertDialog } from './NoteInsertDialog';
14+
15+
const baseProps = {
16+
content: 'AI 整理版内容',
17+
courseName: '高等数学',
18+
sessionSeq: 1,
19+
fetchCourseNotes: vi.fn().mockResolvedValue([]),
20+
appendToNote: vi.fn().mockResolvedValue(undefined),
21+
createCourseNote: vi.fn().mockResolvedValue(undefined),
22+
onDone: vi.fn(),
23+
onClose: vi.fn(),
24+
};
25+
26+
describe('NoteInsertDialog 版本切换', () => {
27+
beforeEach(() => {
28+
Object.defineProperty(navigator, 'clipboard', {
29+
value: { writeText: vi.fn().mockResolvedValue(undefined) },
30+
configurable: true,
31+
});
32+
});
33+
34+
afterEach(() => {
35+
vi.clearAllMocks();
36+
});
37+
38+
it('未提供 rawContent 时不显示切换开关', () => {
39+
render(<NoteInsertDialog {...baseProps} />);
40+
expect(screen.queryByText('AI 整理版')).toBeNull();
41+
expect(screen.queryByText('原始转写')).toBeNull();
42+
});
43+
44+
it('提供 rawContent 时显示切换开关,默认 AI 整理版', () => {
45+
render(<NoteInsertDialog {...baseProps} rawContent="原始转写内容" />);
46+
expect(screen.getByText('AI 整理版')).toBeTruthy();
47+
expect(screen.getByText('原始转写')).toBeTruthy();
48+
// 默认复制 AI 版
49+
fireEvent.click(screen.getByText('仅复制到剪贴板'));
50+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('AI 整理版内容');
51+
});
52+
53+
it('切换到原始转写后复制使用原文内容', async () => {
54+
render(<NoteInsertDialog {...baseProps} rawContent="原始转写内容" />);
55+
fireEvent.click(screen.getByText('原始转写'));
56+
// 预览切换生效(内容预览显示原文)
57+
await waitFor(() => {
58+
expect(screen.getByText(/原始转写内容/)).toBeTruthy();
59+
});
60+
fireEvent.click(screen.getByText('仅复制到剪贴板'));
61+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('原始转写内容');
62+
});
63+
64+
it('创建新笔记使用当前生效版本(原文版)', async () => {
65+
render(<NoteInsertDialog {...baseProps} rawContent="原始转写内容" />);
66+
fireEvent.click(screen.getByText('原始转写'));
67+
// 按钮与选项标题同文案,用 role 精确匹配按钮
68+
fireEvent.click(screen.getByRole('button', { name: /创建新笔记/ }));
69+
await waitFor(() => {
70+
expect(baseProps.createCourseNote).toHaveBeenCalledWith(
71+
expect.stringContaining('高等数学'),
72+
expect.stringContaining('原始转写内容'),
73+
);
74+
});
75+
});
76+
});

‎client/src/features/classroom/components/NoteInsertDialog.tsx‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ export interface CourseNoteItem {
2222
}
2323

2424
interface NoteInsertDialogProps {
25-
/** 待插入的笔记内容(Markdown) */
25+
/** 待插入的笔记内容(Markdown)——AI 整理版(默认) */
2626
content: string;
27+
/** 原始转写版本(Markdown,带时间戳);提供时显示"AI 整理 / 原始转写"切换开关 */
28+
rawContent?: string;
2729
/** 课程名称(用于查询已有笔记和生成标题) */
2830
courseName: string;
2931
/** 当天同课程的采集序号 */
@@ -46,6 +48,7 @@ interface NoteInsertDialogProps {
4648

4749
export function NoteInsertDialog({
4850
content,
51+
rawContent,
4952
courseName,
5053
sessionSeq,
5154
fetchCourseNotes,
@@ -59,6 +62,12 @@ export function NoteInsertDialog({
5962
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(null);
6063
const [dropdownOpen, setDropdownOpen] = useState(false);
6164
const [busy, setBusy] = useState(false);
65+
/** P0 批量插入方案 B:AI 整理版 / 原始转写版切换(rawContent 提供时可用) */
66+
const [useRaw, setUseRaw] = useState(false);
67+
68+
// 当前生效版本:原始转写版存在且被选中时优先,否则 AI 整理版
69+
const activeContent = useRaw && rawContent ? rawContent : content;
70+
const hasRawToggle = !!rawContent && rawContent !== content;
6271

6372
const today = new Date().toLocaleDateString('zh-CN');
6473
const sessionLabel = `${today} 第${sessionSeq}次采集`;
@@ -86,7 +95,7 @@ export function NoteInsertDialog({
8695
if (!selectedNoteId || busy) return;
8796
setBusy(true);
8897
try {
89-
await appendToNote(selectedNoteId, content, sessionLabel);
98+
await appendToNote(selectedNoteId, activeContent, sessionLabel);
9099
const note = existingNotes.find((n) => n.id === selectedNoteId);
91100
onDone(`已追加到「${note?.title ?? '笔记'}」`);
92101
} catch (err) {
@@ -95,14 +104,14 @@ export function NoteInsertDialog({
95104
} finally {
96105
setBusy(false);
97106
}
98-
}, [selectedNoteId, busy, appendToNote, content, sessionLabel, existingNotes, onDone]);
107+
}, [selectedNoteId, busy, appendToNote, activeContent, sessionLabel, existingNotes, onDone]);
99108

100109
// 创建新笔记
101110
const handleCreate = useCallback(async () => {
102111
if (busy) return;
103112
setBusy(true);
104113
try {
105-
const fullContent = `## ${sessionLabel}\n\n${content}`;
114+
const fullContent = `## ${sessionLabel}\n\n${activeContent}`;
106115
await createCourseNote(newNoteTitle, fullContent);
107116
onDone(`已创建笔记「${newNoteTitle}」`);
108117
} catch (err) {
@@ -111,13 +120,13 @@ export function NoteInsertDialog({
111120
} finally {
112121
setBusy(false);
113122
}
114-
}, [busy, content, sessionLabel, newNoteTitle, createCourseNote, onDone]);
123+
}, [busy, activeContent, sessionLabel, newNoteTitle, createCourseNote, onDone]);
115124

116125
// 复制到剪贴板
117126
const handleCopy = useCallback(async () => {
118-
await navigator.clipboard.writeText(content);
127+
await navigator.clipboard.writeText(activeContent);
119128
onDone('已复制到剪贴板');
120-
}, [content, onDone]);
129+
}, [activeContent, onDone]);
121130

122131
const selectedNote = existingNotes.find((n) => n.id === selectedNoteId);
123132

@@ -137,10 +146,30 @@ export function NoteInsertDialog({
137146
</div>
138147

139148
<div className="px-5 py-4 space-y-4">
149+
{/* 版本切换:AI 整理版 / 原始转写版(P0 批量插入方案 B) */}
150+
{hasRawToggle && (
151+
<div className="flex items-center gap-1 p-1 rounded-kb-md bg-bg-secondary/70 border border-border/20 w-fit">
152+
<button
153+
onClick={() => setUseRaw(false)}
154+
className={cn('px-2.5 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
155+
!useRaw ? 'bg-bg-elevated text-brand-600 shadow-kb-xs' : 'text-text-tertiary hover:text-text-secondary')}
156+
>
157+
AI 整理版
158+
</button>
159+
<button
160+
onClick={() => setUseRaw(true)}
161+
className={cn('px-2.5 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
162+
useRaw ? 'bg-bg-elevated text-brand-600 shadow-kb-xs' : 'text-text-tertiary hover:text-text-secondary')}
163+
>
164+
原始转写
165+
</button>
166+
</div>
167+
)}
168+
140169
{/* 内容预览摘要 */}
141170
<div className="px-3 py-2.5 rounded-kb-md bg-bg-secondary/60 border border-border/20 max-h-24 overflow-y-auto">
142171
<p className="text-b3 text-text-tertiary leading-relaxed line-clamp-3">
143-
{content.slice(0, 200)}{content.length > 200 ? '...' : ''}
172+
{activeContent.slice(0, 200)}{activeContent.length > 200 ? '...' : ''}
144173
</p>
145174
</div>
146175

‎client/src/features/classroom/components/SegmentList.tsx‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ interface SegmentListProps {
1616
segments: ExtractedSegment[];
1717
selectedIds: Set<string>;
1818
onToggleSelect: (id: string) => void;
19-
onInsertSelected: () => void;
20-
onInsertAll: () => void;
19+
/** 插入选中:父级拼 Markdown 后回调(打开笔记插入弹窗) */
20+
onInsertSelected: (markdown: string) => void;
21+
onInsertAll: (markdown: string) => void;
2122
}
2223

2324
export function SegmentList({ segments, selectedIds, onToggleSelect, onInsertSelected, onInsertAll }: SegmentListProps) {
@@ -26,17 +27,32 @@ export function SegmentList({ segments, selectedIds, onToggleSelect, onInsertSel
2627
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
2728
}, [segments.length]);
2829

30+
/** 拼接选中/全部片段为 Markdown(带 [HH:MM:SS] 时间戳) */
31+
const buildMarkdown = (list: ExtractedSegment[]): string =>
32+
list
33+
.map((s) => {
34+
const time = new Date(s.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
35+
return `[${time}] ${s.text}`;
36+
})
37+
.join('\n\n');
38+
2939
return (
3040
<div className="flex-1 flex flex-col min-h-0">
3141
<div className="flex items-center justify-between px-4 py-3 border-b border-border/20">
3242
<span className="text-b2 font-medium text-text-secondary">提取结果 ({segments.length})</span>
3343
<div className="flex items-center gap-2">
34-
<button onClick={onInsertSelected} disabled={selectedIds.size === 0}
44+
<button onClick={() => {
45+
const md = buildMarkdown(segments.filter((s) => selectedIds.has(s.id)));
46+
if (md) onInsertSelected(md);
47+
}} disabled={selectedIds.size === 0}
3548
className={cn('inline-flex items-center gap-1 px-2.5 py-1.5 rounded-kb-sm text-b3 font-medium transition-all',
3649
selectedIds.size > 0 ? 'bg-brand-50 text-brand-600 hover:bg-brand-100' : 'text-text-tertiary cursor-not-allowed')}>
3750
<Plus className="w-3.5 h-3.5" strokeWidth={2} /> 插入选中
3851
</button>
39-
<button onClick={onInsertAll} disabled={segments.length === 0}
52+
<button onClick={() => {
53+
const md = buildMarkdown(segments);
54+
if (md) onInsertAll(md);
55+
}} disabled={segments.length === 0}
4056
className={cn('inline-flex items-center gap-1 px-2.5 py-1.5 rounded-kb-sm text-b3 font-medium transition-all',
4157
segments.length > 0 ? 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary' : 'text-text-tertiary cursor-not-allowed')}>
4258
<ListPlus className="w-3.5 h-3.5" strokeWidth={2} /> 全部插入

‎client/src/features/classroom/components/SessionContentView.tsx‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ type TabId = 'content' | 'notes' | 'qa';
3232

3333
interface SessionContentViewProps {
3434
capture: ReturnType<typeof useClassroomCapture>;
35-
/** 打开"插入笔记"弹窗(由页面层持有状态) */
36-
onOpenNoteDialog: () => void;
35+
/** 打开"插入笔记"弹窗(由页面层持有状态);可选 content 覆盖默认 AI 分析结果 */
36+
onOpenNoteDialog: (content?: string) => void;
3737
}
3838

3939
const TAB_LABELS: Record<TabId, string> = {
@@ -58,10 +58,11 @@ export function SessionContentView({ capture, onOpenNoteDialog }: SessionContent
5858
}
5959
}, [isSkillKind]);
6060

61-
// 问答上下文:拼接各路径转写文本(fine 段 + smart 实时转录)
61+
// 问答上下文:拼接各路径转写文本(fine 段 + smart 实时转录;
62+
// smart 优先取用户修正后文本(editedText),保证修正流入问答上下文)
6263
const qaTranscript = capture.capturePath === 'fine'
6364
? capture.segments.map((s) => s.text).join('\n')
64-
: capture.liveTranscripts.map((t) => t.text).join('\n');
65+
: capture.liveTranscripts.map((t) => t.editedText ?? t.text).join('\n');
6566

6667
const notesTabVisible = capture.isAnalyzing || !!capture.analysisResult || !!capture.analysisError;
6768
const qaTabVisible = qaTranscript.trim().length > 0;
@@ -139,6 +140,7 @@ export function SessionContentView({ capture, onOpenNoteDialog }: SessionContent
139140
isActive={capture.status === 'capturing' && (capture.mode === 'audio' || capture.mode === 'mixed')}
140141
onEditTranscript={capture.handleEditTranscript}
141142
onCycleSpeaker={capture.handleCycleSpeaker}
143+
onInsertToNote={(markdown) => onOpenNoteDialog(markdown)}
142144
/>
143145
)}
144146
</>
@@ -149,14 +151,8 @@ export function SessionContentView({ capture, onOpenNoteDialog }: SessionContent
149151
segments={capture.segments}
150152
selectedIds={capture.selectedIds}
151153
onToggleSelect={capture.handleToggleSelect}
152-
onInsertSelected={() => {
153-
const texts = capture.segments.filter((s) => capture.selectedIds.has(s.id)).map((s) => s.text).join('\n\n');
154-
if (texts) navigator.clipboard.writeText(texts);
155-
}}
156-
onInsertAll={() => {
157-
const texts = capture.segments.map((s) => s.text).join('\n\n');
158-
if (texts) navigator.clipboard.writeText(texts);
159-
}}
154+
onInsertSelected={(markdown) => onOpenNoteDialog(markdown)}
155+
onInsertAll={(markdown) => onOpenNoteDialog(markdown)}
160156
/>
161157
)}
162158

‎client/src/features/classroom/components/UnifiedTimeline.test.tsx‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* @ai-context: 验证时间轴事件与实时转写按时间戳合并排序的正确性,
44
* 以及空态/实时行渲染。纯 jsdom 渲染,不依赖 Electron。
55
*/
6-
import { describe, it, expect } from 'vitest';
7-
import { render, screen } from '@testing-library/react';
6+
import { describe, it, expect, vi } from 'vitest';
7+
import { render, screen, fireEvent } from '@testing-library/react';
88
import { UnifiedTimeline } from './UnifiedTimeline';
99

1010
const baseBundle = {
@@ -90,3 +90,99 @@ describe('UnifiedTimeline 合并排序', () => {
9090
expect(screen.getByText('正在识别这句…')).toBeTruthy();
9191
});
9292
});
93+
94+
describe('UnifiedTimeline 批量插入', () => {
95+
const transcripts = [
96+
{ id: 't1', text: '原始转写一', timestamp: 6000 },
97+
{ id: 't2', text: '原始转写二', editedText: '修正后的转写二', timestamp: 12000 },
98+
];
99+
100+
it('课后(非采集中)展示勾选框与插入笔记操作栏', () => {
101+
render(
102+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} onInsertToNote={() => {}} />,
103+
);
104+
// 操作栏提示与按钮可见
105+
expect(screen.getByText(/勾选转写行后可批量插入笔记/)).toBeTruthy();
106+
expect(screen.getByText('插入笔记')).toBeTruthy();
107+
});
108+
109+
it('采集中不展示勾选与插入操作栏(避免干扰实时显示)', () => {
110+
render(
111+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive onInsertToNote={() => {}} />,
112+
);
113+
expect(screen.queryByText('插入笔记')).toBeNull();
114+
});
115+
116+
it('未提供 onInsertToNote 时不展示插入操作栏', () => {
117+
render(<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} />);
118+
expect(screen.queryByText('插入笔记')).toBeNull();
119+
});
120+
121+
it('勾选转写行后插入:拼接带 [HH:MM:SS] 时间戳的 Markdown,修正后文本优先', () => {
122+
const onInsert = vi.fn();
123+
render(
124+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} onInsertToNote={onInsert} />,
125+
);
126+
127+
// 勾选两行(行首圆形勾选按钮,title 提示)
128+
const checkboxes = screen.getAllByTitle('勾选后插入笔记');
129+
expect(checkboxes.length).toBe(2);
130+
fireEvent.click(checkboxes[0]);
131+
fireEvent.click(checkboxes[1]);
132+
// 操作栏计数("已勾选 <strong>N</strong> 句转写":getByText 只拼直接文本子节点,
133+
// strong 内数字被丢弃,故直接断言操作栏 DOM 文本)
134+
const bar = document.querySelector('[class*="border-t"]');
135+
expect(bar?.textContent).toContain('已勾选');
136+
expect(bar?.textContent).toContain('2');
137+
expect(bar?.textContent).toContain('句转写');
138+
139+
fireEvent.click(screen.getByText('插入笔记'));
140+
expect(onInsert).toHaveBeenCalledTimes(1);
141+
142+
const markdown = onInsert.mock.calls[0][0] as string;
143+
// 每行带 [HH:MM:SS] 时间戳前缀,修正后文本优先(t2 用 editedText)
144+
expect(markdown).toMatch(/^\[\d{2}:\d{2}:\d{2}\] 原始转写一$/m);
145+
expect(markdown).toMatch(/\[\d{2}:\d{2}:\d{2}\] 修正后的转写二$/m);
146+
expect(markdown).not.toContain('原始转写二');
147+
});
148+
149+
it('未勾选任何行时插入按钮禁用', () => {
150+
const onInsert = vi.fn();
151+
render(
152+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} onInsertToNote={onInsert} />,
153+
);
154+
const btn = screen.getByText('插入笔记') as HTMLButtonElement;
155+
expect(btn.disabled).toBe(true);
156+
});
157+
158+
it('插入成功后清空勾选(防重复点击重复插入)', () => {
159+
const onInsert = vi.fn();
160+
render(
161+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} onInsertToNote={onInsert} />,
162+
);
163+
fireEvent.click(screen.getAllByTitle('勾选后插入笔记')[0]);
164+
fireEvent.click(screen.getByText('插入笔记'));
165+
expect(onInsert).toHaveBeenCalledTimes(1);
166+
// 勾选已清空:操作栏回到提示态,按钮重新禁用
167+
expect(screen.getByText(/勾选转写行后可批量插入笔记/)).toBeTruthy();
168+
expect((screen.getByText('插入笔记') as HTMLButtonElement).disabled).toBe(true);
169+
});
170+
171+
it('liveTranscripts 截断(FIFO)后失效勾选被清理,计数随之修正', () => {
172+
const { rerender } = render(
173+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={transcripts} isActive={false} onInsertToNote={() => {}} />,
174+
);
175+
const boxes = screen.getAllByTitle('勾选后插入笔记');
176+
fireEvent.click(boxes[0]);
177+
fireEvent.click(boxes[1]);
178+
const bar1 = document.querySelector('[class*="border-t"]');
179+
expect(bar1?.textContent).toContain('2');
180+
181+
// FIFO 截断:t2 被移除 → 失效勾选自动清理,计数降至 1
182+
rerender(
183+
<UnifiedTimeline bundle={baseBundle} liveTranscripts={[transcripts[0]]} isActive={false} onInsertToNote={() => {}} />,
184+
);
185+
const bar2 = document.querySelector('[class*="border-t"]');
186+
expect(bar2?.textContent).toContain('1');
187+
});
188+
});

0 commit comments

Comments
 (0)