Skip to content

Commit 19b03e1

Browse files
committed
feat(classroom): P2-7 步骤化笔记 + P2-8 步骤复习联动
stepExtractor: slide_change 边界切分 + 指令句聚合(1s 时序容差)+ 代表性截图选择;checklist/闪卡问答转换 StepFlowView: 步骤卡片流(截图+说明+时间戳)+ 练习模式(隐藏说明翻卡对照)+ 跟着做 checklist + 闪卡导出 SessionContentView: 技能类会话时间线/步骤卡片视图切换(首次检测自动切步骤) 新增 8 例单测(边界切分/容差/兜底/截断/转换)
1 parent 3296c4d commit 19b03e1

4 files changed

Lines changed: 451 additions & 10 deletions

File tree

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

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@
1212
* live timeline state survives tab switches. Notes tab previews the
1313
* AI-processed result before inserting into a note.
1414
*/
15-
import { useState } from 'react';
15+
import { useState, useEffect, useRef } from 'react';
1616
import { useNavigate } from 'react-router-dom';
1717
import { cn } from '@/lib/utils';
1818
import { useClassroomCapture } from '../hooks/useClassroomCapture';
1919
import { SegmentList } from './SegmentList';
2020
import { UnifiedTimeline } from './UnifiedTimeline';
2121
import { ClassroomStatusBanners } from './ClassroomStatusBanners';
2222
import { RecognitionStatsBar } from './RecognitionStatsBar';
23+
import { StepFlowView } from './StepFlowView';
2324
import { isLocalAsrReady } from '../utils/asrTranscriber';
2425
import { AnalysisPreview } from '@/features/notes/components/AnalysisPreview';
2526
import { VideoRecordPanel } from '@/features/notes/components/VideoRecordPanel';
@@ -44,6 +45,18 @@ const TAB_LABELS: Record<TabId, string> = {
4445
export function SessionContentView({ capture, onOpenNoteDialog }: SessionContentViewProps) {
4546
const [tab, setTab] = useState<TabId>('content');
4647
const navigate = useNavigate();
48+
/** P2-7:内容视图子模式(技能类会话可用步骤卡片流) */
49+
const [viewMode, setViewMode] = useState<'timeline' | 'steps'>('timeline');
50+
const isSkillKind = capture.contentKind === 'software_skill' || capture.contentKind === 'craft_skill';
51+
52+
// 首次检测到技能类内容时自动切到步骤视图(会话中途检测,仅切换一次)
53+
const skillDetectedRef = useRef(false);
54+
useEffect(() => {
55+
if (isSkillKind && !skillDetectedRef.current) {
56+
skillDetectedRef.current = true;
57+
setViewMode('steps');
58+
}
59+
}, [isSkillKind]);
4760

4861
// 问答上下文:拼接各路径转写文本(fine 段 + smart 实时转录)
4962
const qaTranscript = capture.capturePath === 'fine'
@@ -95,16 +108,39 @@ export function SessionContentView({ capture, onOpenNoteDialog }: SessionContent
95108
streamingAsrActive={capture.streamingAsrActive}
96109
localAsrReady={isLocalAsrReady()}
97110
/>
111+
{/* P2-7 技能类会话:时间线 / 步骤卡片流切换 */}
112+
{isSkillKind && (
113+
<div className="mx-4 mt-2 flex gap-1">
114+
{(['timeline', 'steps'] as const).map((mode) => (
115+
<button
116+
key={mode}
117+
onClick={() => setViewMode(mode)}
118+
className={cn(
119+
'px-2.5 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
120+
viewMode === mode
121+
? 'bg-brand-50 text-brand-600 ring-1 ring-brand-200/50'
122+
: 'text-text-tertiary hover:bg-bg-tertiary',
123+
)}
124+
>
125+
{mode === 'timeline' ? '时间线' : '步骤卡片'}
126+
</button>
127+
))}
128+
</div>
129+
)}
98130
{/* M2 自动锚点以时间线行展示(autoAnchors 独立数据源) */}
99-
<UnifiedTimeline
100-
bundle={capture.smartBundle}
101-
liveTranscripts={capture.liveTranscripts}
102-
autoAnchors={capture.autoAnchors}
103-
partialText={capture.partialText}
104-
isActive={capture.status === 'capturing' && (capture.mode === 'audio' || capture.mode === 'mixed')}
105-
onEditTranscript={capture.handleEditTranscript}
106-
onCycleSpeaker={capture.handleCycleSpeaker}
107-
/>
131+
{viewMode === 'steps' && isSkillKind ? (
132+
<StepFlowView bundle={capture.smartBundle} />
133+
) : (
134+
<UnifiedTimeline
135+
bundle={capture.smartBundle}
136+
liveTranscripts={capture.liveTranscripts}
137+
autoAnchors={capture.autoAnchors}
138+
partialText={capture.partialText}
139+
isActive={capture.status === 'capturing' && (capture.mode === 'audio' || capture.mode === 'mixed')}
140+
onEditTranscript={capture.handleEditTranscript}
141+
onCycleSpeaker={capture.handleCycleSpeaker}
142+
/>
143+
)}
108144
</>
109145
)}
110146

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/**
2+
* StepFlowView — 步骤化笔记视图(P2-7/P2-8)
3+
*
4+
* @ai-context: 技能场景(软件/手法技巧)的步骤卡片流:每步 = 截图 + 操作
5+
* 说明 + 时间戳;支持「练习模式」(隐藏说明看截图回忆操作,翻卡对照,
6+
* 费曼式主动回忆)、「跟着做」checklist 勾选、步骤→闪卡问答导出。
7+
* 数据源为 smart 会话数据经 extractSteps 提取(内存态,会话内可用)。
8+
* @ai-context EN: Step-card flow for skill videos: screenshot + instruction
9+
* + timestamp per step. Practice mode hides instructions for active recall;
10+
* checklist tracks progress; cards export as Q&A flashcards.
11+
*/
12+
import { useMemo, useState } from 'react';
13+
import { Eye, EyeOff, CheckSquare, Copy, Layers } from 'lucide-react';
14+
import { cn } from '@/lib/utils';
15+
import {
16+
extractSteps,
17+
stepsToChecklist,
18+
stepsToFlashcards,
19+
} from '@/lib/capture/stepExtractor';
20+
import type { SessionBundle } from '@/lib/capture';
21+
22+
interface StepFlowViewProps {
23+
bundle: Partial<SessionBundle>;
24+
/** 是否显示操作说明(练习模式=false 隐藏,点击单卡对照) */
25+
initiallyPractice?: boolean;
26+
}
27+
28+
export function StepFlowView({ bundle, initiallyPractice = false }: StepFlowViewProps) {
29+
const [practice, setPractice] = useState(initiallyPractice);
30+
/** 练习模式下被翻开的卡片 id 集合(对照查看) */
31+
const [revealed, setRevealed] = useState<Set<string>>(new Set());
32+
const [checked, setChecked] = useState<Set<string>>(new Set());
33+
const [copied, setCopied] = useState(false);
34+
35+
const { steps, boundaryCount, commandCueCount } = useMemo(
36+
() => extractSteps(bundle.keyframes ?? [], bundle.audioSegments ?? []),
37+
[bundle.keyframes, bundle.audioSegments],
38+
);
39+
const checklist = useMemo(() => stepsToChecklist(steps), [steps]);
40+
41+
const toggleReveal = (id: string) => {
42+
setRevealed((prev) => {
43+
const next = new Set(prev);
44+
if (next.has(id)) next.delete(id);
45+
else next.add(id);
46+
return next;
47+
});
48+
};
49+
50+
const toggleCheck = (id: string) => {
51+
setChecked((prev) => {
52+
const next = new Set(prev);
53+
if (next.has(id)) next.delete(id);
54+
else next.add(id);
55+
return next;
56+
});
57+
};
58+
59+
/** 导出步骤闪卡问答(复制到剪贴板,供闪卡模块导入) */
60+
const exportFlashcards = async () => {
61+
const cards = stepsToFlashcards(steps);
62+
const text = cards.map((c) => `${c.front}\n答:${c.back}`).join('\n\n---\n\n');
63+
await navigator.clipboard.writeText(text);
64+
setCopied(true);
65+
window.setTimeout(() => setCopied(false), 2000);
66+
};
67+
68+
if (steps.length === 0) {
69+
return (
70+
<div className="flex-1 flex flex-col items-center justify-center py-10 text-text-tertiary">
71+
<Layers className="w-8 h-8 mb-2 opacity-30" strokeWidth={1} />
72+
<p className="text-b3">技能类内容(界面切换 + 操作指令)将自动生成步骤卡片</p>
73+
</div>
74+
);
75+
}
76+
77+
return (
78+
<div className="flex-1 min-h-0 flex flex-col">
79+
{/* 工具栏:练习模式 / 统计 / 闪卡导出 */}
80+
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/20 flex-shrink-0">
81+
<button
82+
onClick={() => setPractice((p) => !p)}
83+
className={cn(
84+
'flex items-center gap-1 px-2 py-1 rounded-kb-sm text-[11px] font-medium transition-all',
85+
practice
86+
? 'bg-amber-50 text-amber-600 ring-1 ring-amber-200/50'
87+
: 'text-text-tertiary hover:bg-bg-tertiary',
88+
)}
89+
>
90+
{practice ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
91+
{practice ? '练习模式(点击卡片对照)' : '查看模式'}
92+
</button>
93+
<span className="text-[10px] text-text-tertiary">
94+
{steps.length} 步 · {boundaryCount} 次界面切换 · {commandCueCount} 条操作指令
95+
</span>
96+
<button
97+
onClick={exportFlashcards}
98+
className="ml-auto flex items-center gap-1 px-2 py-1 rounded-kb-sm text-[11px] text-text-tertiary hover:text-cyber hover:bg-cyber/5 transition-all"
99+
>
100+
<Copy className="w-3 h-3" />
101+
{copied ? '已复制闪卡问答' : '导出步骤闪卡'}
102+
</button>
103+
</div>
104+
105+
{/* 步骤卡片流 */}
106+
<div className="flex-1 overflow-y-auto p-3 space-y-2">
107+
{steps.map((step, idx) => {
108+
const isRevealed = !practice || revealed.has(step.id);
109+
return (
110+
<div
111+
key={step.id}
112+
className={cn(
113+
'rounded-kb-md border border-border/20 bg-bg-elevated/40 p-3',
114+
practice && 'cursor-pointer transition-colors hover:bg-bg-elevated',
115+
)}
116+
onClick={practice ? () => toggleReveal(step.id) : undefined}
117+
>
118+
<div className="flex items-start gap-3">
119+
{/* 步骤截图 */}
120+
{step.imageBase64 && (
121+
<img
122+
src={`data:image/jpeg;base64,${step.imageBase64}`}
123+
alt={`步骤 ${idx + 1} 截图`}
124+
className="w-36 rounded-kb-sm object-cover border border-border/30 flex-shrink-0"
125+
/>
126+
)}
127+
<div className="flex-1 min-w-0">
128+
<div className="flex items-center gap-2">
129+
<span className="text-[10px] text-text-tertiary font-mono">
130+
{new Date(step.timestamp).toLocaleTimeString()}
131+
</span>
132+
<span className="text-b3 font-medium text-text-primary">{step.title}</span>
133+
</div>
134+
<p className={cn(
135+
'mt-1 text-[12px] leading-relaxed text-text-secondary',
136+
practice && !isRevealed && 'opacity-0 select-none',
137+
practice && !isRevealed && 'h-4', // 占位防跳动
138+
)}>
139+
{isRevealed ? (step.instruction || '(本步无口述说明,观察截图)') : '••• 回忆一下这一步做了什么 •••'}
140+
</p>
141+
</div>
142+
</div>
143+
</div>
144+
);
145+
})}
146+
</div>
147+
148+
{/* 跟着做 checklist */}
149+
<div className="border-t border-border/20 px-3 py-2 flex-shrink-0">
150+
<div className="flex items-center gap-1.5 mb-1.5">
151+
<CheckSquare className="w-3.5 h-3.5 text-emerald-500" strokeWidth={1.5} />
152+
<span className="text-[11px] font-medium text-text-tertiary">
153+
跟着做({checked.size}/{checklist.length})
154+
</span>
155+
</div>
156+
<div className="flex flex-wrap gap-1.5 max-h-20 overflow-y-auto">
157+
{checklist.map((item) => (
158+
<button
159+
key={item.id}
160+
onClick={() => toggleCheck(item.id)}
161+
className={cn(
162+
'px-2 py-0.5 rounded-kb-sm text-[11px] border transition-all',
163+
checked.has(item.id)
164+
? 'border-emerald-300/50 bg-emerald-50 text-emerald-600 line-through'
165+
: 'border-border/30 text-text-secondary hover:border-emerald-300/50',
166+
)}
167+
>
168+
{item.label}
169+
</button>
170+
))}
171+
</div>
172+
</div>
173+
</div>
174+
);
175+
}
176+
177+
export default StepFlowView;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* extractSteps 单元测试(P2-7 步骤提取)
3+
*
4+
* @ai-context: 锁定步骤提取契约:slide_change 边界切分、时间窗内指令句
5+
* 聚合、代表性截图选择、无边界帧兜底单步骤、checklist/闪卡转换。
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { extractSteps, stepsToChecklist, stepsToFlashcards } from './stepExtractor';
9+
import type { KeyFrame, AudioSegment } from './captureTypes';
10+
11+
let ts = 1_000_000;
12+
function kf(changeType: KeyFrame['changeType'], imageBase64 = '', offsetMs = 0): KeyFrame {
13+
ts += 5000;
14+
return {
15+
id: `kf-${ts}`,
16+
timestamp: ts + offsetMs,
17+
imageBase64,
18+
changeType,
19+
};
20+
}
21+
22+
function seg(start: number, text: string): AudioSegment {
23+
return {
24+
id: `seg-${start}`,
25+
timestampStart: start,
26+
timestampEnd: start + 3000,
27+
audioBase64: '',
28+
energy: 0.1,
29+
audioText: text,
30+
};
31+
}
32+
33+
describe('extractSteps', () => {
34+
it('slide_change 边界切分步骤,指令句聚合为说明', () => {
35+
const f1 = kf('slide_change', 'img1'); // 边界1
36+
const f2 = kf('writing', 'img2');
37+
const f3 = kf('slide_change', 'img3'); // 边界2
38+
const f4 = kf('writing', 'img4');
39+
const segments = [
40+
seg(f1.timestamp + 500, '我们点击这个按钮'),
41+
seg(f2.timestamp + 500, '把不透明度设置为五十'),
42+
seg(f3.timestamp + 500, '然后拖到图层面板'),
43+
seg(f4.timestamp + 500, '普通讲解内容'),
44+
];
45+
const result = extractSteps([f1, f2, f3, f4], segments);
46+
expect(result.boundaryCount).toBe(2);
47+
expect(result.steps).toHaveLength(2);
48+
expect(result.steps[0].instruction).toContain('点击这个按钮');
49+
expect(result.steps[0].imageBase64).toBe('img2');
50+
expect(result.steps[1].instruction).toContain('拖到图层面板');
51+
});
52+
53+
it('无边界帧:整体单步骤兜底', () => {
54+
const f1 = kf('writing', 'img1');
55+
const f2 = kf('writing', 'img2');
56+
const result = extractSteps([f1, f2], [seg(f1.timestamp, '点击这个按钮')]);
57+
expect(result.boundaryCount).toBe(0);
58+
expect(result.steps).toHaveLength(1);
59+
expect(result.steps[0].title).toBe('点击这个按钮');
60+
});
61+
62+
it('无指令句时标题为「步骤 N」', () => {
63+
const f1 = kf('slide_change', 'img1');
64+
const result = extractSteps([f1], [seg(f1.timestamp, '普通讲解')]);
65+
expect(result.steps[0].title).toBe('步骤 1');
66+
expect(result.steps[0].instruction).toBe('');
67+
});
68+
69+
it('边界前后 1s 容差覆盖时序差', () => {
70+
const f1 = kf('slide_change', 'img1');
71+
const result = extractSteps(
72+
[f1],
73+
[seg(f1.timestamp - 800, '点击这个按钮')], // 边界前 800ms
74+
);
75+
expect(result.steps[0].instruction).toContain('点击这个按钮');
76+
});
77+
78+
it('空数据:无步骤无异常', () => {
79+
const result = extractSteps([], []);
80+
expect(result.steps).toHaveLength(0);
81+
expect(result.commandCueCount).toBe(0);
82+
});
83+
84+
it('超长标题截断 40 字', () => {
85+
const f1 = kf('slide_change', 'img1');
86+
const longText = '我们点击这个按钮然后把它拖到图层面板再调整参数设置为百分之五十的数值'.repeat(2);
87+
const result = extractSteps([f1], [seg(f1.timestamp + 100, longText)]);
88+
expect(result.steps[0].title.length).toBeLessThanOrEqual(40);
89+
});
90+
});
91+
92+
describe('stepsToChecklist / stepsToFlashcards(P2-8)', () => {
93+
it('checklist 转换', () => {
94+
const steps = [
95+
{ id: 's1', timestamp: 1, title: '步骤 1', instruction: '' },
96+
{ id: 's2', timestamp: 2, title: '步骤 2', instruction: '' },
97+
];
98+
const list = stepsToChecklist(steps);
99+
expect(list).toHaveLength(2);
100+
expect(list[0].label).toBe('步骤 1');
101+
});
102+
103+
it('闪卡转换:问答形态', () => {
104+
const steps = [
105+
{ id: 's1', timestamp: 1, title: '点击图层面板', instruction: '点击图层面板并拖动' },
106+
{ id: 's2', timestamp: 2, title: '步骤 2', instruction: '调整参数' },
107+
];
108+
const cards = stepsToFlashcards(steps);
109+
expect(cards).toHaveLength(2);
110+
expect(cards[0].front).toContain('点击图层面板');
111+
expect(cards[0].back).toBe('点击图层面板并拖动');
112+
expect(cards[1].front).toBe('这一步(截图/操作)是什么?');
113+
});
114+
});

0 commit comments

Comments
 (0)