Skip to content

Commit 2f1a644

Browse files
committed
feat(classroom): 课堂模块增强——热词/转录编辑/章节导航/视觉模式
- hotwordRuntime: 热词替换+增强双通道,audioTextRaw 保真溯源 - asrTranscriber: 并发上限 3→5,队列上限 10→20,超时 15s→30s,热词透传 - LiveTranscript: 新增内联编辑功能,已修正标记+原始文本 tooltip - ClassroomPage: 引入章节导航组件(ChapterNav) - 新增 ChapterNav: 从笔记 Markdown 提取章节导航 - 新增 VisionModeSelector: 视觉提取模式选择器 - 新增 oralCleanup: 口语书面化清理工具 - AsrModelPrompt: 修复 DOM 结构
1 parent bf2c882 commit 2f1a644

15 files changed

Lines changed: 501 additions & 57 deletions

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,6 @@ export function AsrModelPrompt() {
108108
<p className="mt-1.5 text-c1 text-text-tertiary">
109109
下载后可完全离线转写语音,无需联网、零 API 费用。不下载也可正常使用(云端转写)。
110110
</p>
111-
112-
<div className="mt-3 space-y-2">
113111
{models.map((model) => (
114112
<div key={model.id} className="flex items-center gap-3 p-2.5 rounded-kb-md bg-bg-secondary/50">
115113
<div className="flex-1 min-w-0">
@@ -143,7 +141,6 @@ export function AsrModelPrompt() {
143141
)}
144142
</div>
145143
))}
146-
</div>
147144

148145
{/* 下载进度条 */}
149146
{downloading && (
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* 章节速览导航组件(P2-2)
3+
* 从 Markdown 笔记中提取标题层级,生成可点击的章节导航条。
4+
* 点击跳转到对应章节位置,支持多级标题展开/折叠。
5+
*
6+
* @ai-context: P2-2 章节速览——纯客户端实现,从分析结果 Markdown 中
7+
* 提取 ## 和 ### 标题构建章节树。离线可用,无需云端依赖。
8+
*/
9+
import { useMemo, useState } from 'react';
10+
import { ListTree, ChevronRight, ChevronDown } from 'lucide-react';
11+
import { cn } from '@/lib/utils';
12+
13+
interface Chapter {
14+
level: number;
15+
title: string;
16+
index: number;
17+
}
18+
19+
interface ChapterNavProps {
20+
/** Markdown 笔记内容 */
21+
content: string;
22+
/** 点击章节时回调(可滚动到对应位置) */
23+
onChapterClick?: (index: number) => void;
24+
className?: string;
25+
}
26+
27+
export function ChapterNav({ content, onChapterClick, className }: ChapterNavProps) {
28+
const [collapsed, setCollapsed] = useState(false);
29+
30+
const chapters = useMemo(() => {
31+
if (!content) return [];
32+
const lines = content.split('\n');
33+
const result: Chapter[] = [];
34+
let idx = 0;
35+
for (const line of lines) {
36+
const match = line.match(/^(#{2,3})\s+(.+)/);
37+
if (match) {
38+
result.push({ level: match[1].length, title: match[2].trim(), index: idx });
39+
}
40+
idx++;
41+
}
42+
return result;
43+
}, [content]);
44+
45+
if (chapters.length === 0) return null;
46+
47+
return (
48+
<div className={cn('border-t border-border/20', className)}>
49+
<button
50+
onClick={() => setCollapsed(!collapsed)}
51+
className="flex w-full items-center gap-2 px-4 py-2.5 text-b3 text-text-secondary transition-colors hover:text-brand-400 hover:bg-brand-500/5"
52+
>
53+
{collapsed ? <ChevronRight className="w-4 h-4" strokeWidth={1.5} /> : <ChevronDown className="w-4 h-4" strokeWidth={1.5} />}
54+
<ListTree className="w-4 h-4" strokeWidth={1.5} />
55+
<span className="font-medium">章节速览</span>
56+
<span className="ml-auto text-c1 text-text-tertiary">{chapters.length} 节</span>
57+
</button>
58+
59+
{!collapsed && (
60+
<div className="px-3 pb-2 space-y-0.5">
61+
{chapters.map((ch, i) => (
62+
<button
63+
key={i}
64+
onClick={() => onChapterClick?.(ch.index)}
65+
className={cn(
66+
'flex w-full items-center gap-2 px-2 py-1 rounded-kb-md text-left transition-colors',
67+
ch.level === 2
68+
? 'text-b3 text-text-primary hover:bg-bg-secondary font-medium'
69+
: 'text-c1 text-text-tertiary hover:bg-bg-secondary ml-4',
70+
)}
71+
>
72+
<span className="flex-shrink-0 w-1.5 h-1.5 rounded-full bg-brand-400/60" />
73+
<span className="truncate">{ch.title}</span>
74+
</button>
75+
))}
76+
</div>
77+
)}
78+
</div>
79+
);
80+
}

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

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
/**
2-
* LiveTranscript — 实时 ASR 转录滚动面板
3-
* 课中逐句显示语音转写结果,供用户确认采集质量
2+
* LiveTranscript — 实时 ASR 转录滚动面板(P1-2:支持内联编辑与已修正标记)
3+
* 课中逐句显示语音转写结果,供用户确认采集质量;课后可点击编辑修正 ASR 错误。
44
*
5-
* @ai-context: 通用组件:LiveTranscript。
5+
* @ai-context: 通用组件:LiveTranscript。P1-2 新增内联编辑功能:双击/点击
6+
* 编辑图标进入编辑模式,回车保存,ESC 取消。已修正的条目显示"已修正"标记,
7+
* 原始文本通过 tooltip 可回溯。
68
*/
7-
import { useRef, useEffect } from 'react';
8-
import { Mic } from 'lucide-react';
9+
import { useRef, useEffect, useState, useCallback } from 'react';
10+
import { Mic, Pencil, Check, X } from 'lucide-react';
911
import { cn } from '@/lib/utils';
1012

11-
interface TranscriptEntry {
13+
export interface TranscriptEntry {
1214
id: string;
1315
text: string;
1416
timestamp: number;
17+
/** P1-2:用户修正后的文本(存在时优先显示) */
18+
editedText?: string;
1519
}
1620

1721
interface LiveTranscriptProps {
@@ -20,11 +24,18 @@ interface LiveTranscriptProps {
2024
className?: string;
2125
/** 真流式进行中的 partial 文本(实时行,断句后清空) */
2226
partialText?: string;
27+
/** P1-2:编辑回调——用户保存修正文本时调用,回写存储层 */
28+
onEditTranscript?: (id: string, newText: string) => void;
2329
}
2430

25-
export function LiveTranscript({ transcripts, isActive, className, partialText }: LiveTranscriptProps) {
31+
export function LiveTranscript({ transcripts, isActive, className, partialText, onEditTranscript }: LiveTranscriptProps) {
2632
const scrollRef = useRef<HTMLDivElement>(null);
2733
const lastCountRef = useRef(0);
34+
/** 当前正在编辑的条目 ID */
35+
const [editingId, setEditingId] = useState<string | null>(null);
36+
/** 编辑中的临时文本 */
37+
const [editText, setEditText] = useState('');
38+
const inputRef = useRef<HTMLInputElement>(null);
2839

2940
// 新转录到达时自动滚到底部
3041
useEffect(() => {
@@ -41,6 +52,33 @@ export function LiveTranscript({ transcripts, isActive, className, partialText }
4152
}
4253
}, [partialText]);
4354

55+
// 进入编辑模式时自动聚焦输入框
56+
useEffect(() => {
57+
if (editingId && inputRef.current) {
58+
inputRef.current.focus();
59+
inputRef.current.select();
60+
}
61+
}, [editingId]);
62+
63+
const startEditing = useCallback((entry: TranscriptEntry) => {
64+
if (isActive) return; // 采集中不允许编辑(避免干扰实时显示)
65+
setEditingId(entry.id);
66+
setEditText(entry.editedText ?? entry.text);
67+
}, [isActive]);
68+
69+
const saveEdit = useCallback(() => {
70+
if (editingId && editText.trim() && onEditTranscript) {
71+
onEditTranscript(editingId, editText.trim());
72+
}
73+
setEditingId(null);
74+
setEditText('');
75+
}, [editingId, editText, onEditTranscript]);
76+
77+
const cancelEdit = useCallback(() => {
78+
setEditingId(null);
79+
setEditText('');
80+
}, []);
81+
4482
if (transcripts.length === 0 && !isActive && !partialText) return null;
4583

4684
return (
@@ -69,21 +107,60 @@ export function LiveTranscript({ transcripts, isActive, className, partialText }
69107
)}
70108
{transcripts.map((entry, idx) => {
71109
const isLatest = idx === transcripts.length - 1;
110+
const isEditing = editingId === entry.id;
111+
const displayText = entry.editedText ?? entry.text;
112+
const isEdited = !!entry.editedText;
72113
const time = new Date(entry.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
114+
115+
if (isEditing) {
116+
return (
117+
<div key={entry.id} className="flex gap-1.5 items-start">
118+
<span className="text-[10px] text-text-tertiary flex-shrink-0 mt-1 tabular-nums">{time}</span>
119+
<input
120+
ref={inputRef}
121+
value={editText}
122+
onChange={(e) => setEditText(e.target.value)}
123+
onKeyDown={(e) => { if (e.key === 'Enter') saveEdit(); if (e.key === 'Escape') cancelEdit(); }}
124+
className="flex-1 min-w-0 px-2 py-1 rounded-kb-sm border border-cyan-400/50 bg-bg-elevated text-[12px] text-text-primary focus:outline-none"
125+
/>
126+
<button onClick={saveEdit} className="p-1 rounded-kb-sm text-emerald-600 hover:bg-emerald-500/10" title="保存">
127+
<Check className="w-3.5 h-3.5" strokeWidth={1.5} />
128+
</button>
129+
<button onClick={cancelEdit} className="p-1 rounded-kb-sm text-text-tertiary hover:bg-bg-secondary" title="取消">
130+
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
131+
</button>
132+
</div>
133+
);
134+
}
135+
73136
return (
74137
<div
75138
key={entry.id}
76139
className={cn(
77-
'flex gap-2 text-[12px] leading-relaxed transition-opacity duration-300',
140+
'flex gap-2 text-[12px] leading-relaxed transition-opacity duration-300 group',
78141
isLatest ? 'text-text-primary opacity-100' : 'text-text-secondary opacity-80',
79142
)}
80143
>
81144
<span className="text-[10px] text-text-tertiary flex-shrink-0 mt-0.5 tabular-nums">{time}</span>
82-
<span className={cn(isLatest && 'font-medium')}>{entry.text}</span>
145+
<span className={cn('flex-1', isLatest && 'font-medium')}>
146+
{displayText}
147+
{isEdited && (
148+
<span className="ml-1.5 text-[10px] text-amber-500 font-medium" title={`原始: ${entry.text}`}>已修正</span>
149+
)}
150+
</span>
151+
{!isActive && (
152+
<button
153+
onClick={() => startEditing(entry)}
154+
className="p-0.5 rounded-kb-sm text-text-quaternary opacity-0 group-hover:opacity-100 hover:text-cyan-500 hover:bg-cyan-500/10 transition-all flex-shrink-0"
155+
title="编辑修正"
156+
>
157+
<Pencil className="w-3 h-3" strokeWidth={1.5} />
158+
</button>
159+
)}
83160
</div>
84161
);
85162
})}
86-
{/* 真流式进行中的实时行(边说边出,断句后转为已提交条目) */}
163+
{/* 真流式进行中的实时行 */}
87164
{partialText && (
88165
<div className="flex gap-2 text-[12px] leading-relaxed text-brand-600">
89166
<span className="text-[10px] text-brand-400 flex-shrink-0 mt-0.5">▍</span>
@@ -93,4 +170,4 @@ export function LiveTranscript({ transcripts, isActive, className, partialText }
93170
</div>
94171
</div>
95172
);
96-
}
173+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* 视觉提取模式选择器(P8)
3+
* Vision mode selector
4+
*
5+
* @ai-context: 课堂助手配置区的视觉提取模式选择——写入 CaptureManager →
6+
* 截图消息 metadata.visionMode → VisionWorker 按模式提取(text/formula/
7+
* diagram/code/full)。auto=让 VisionWorker 默认策略(全量提取)。
8+
*
9+
* @ai-context: Vision extraction mode selector for the classroom assistant;
10+
* feeds message.metadata.visionMode consumed by VisionWorker.
11+
*/
12+
import { cn } from '@/lib/utils';
13+
14+
export type VisionMode = 'auto' | 'text' | 'formula' | 'diagram' | 'code' | 'full';
15+
16+
const MODES: Array<{ value: VisionMode; label: string; hint: string }> = [
17+
{ value: 'auto', label: '自动', hint: '默认全量提取' },
18+
{ value: 'text', label: '文字', hint: '侧重板书文字' },
19+
{ value: 'formula', label: '公式', hint: '侧重数学公式' },
20+
{ value: 'diagram', label: '图表', hint: '侧重图形图表' },
21+
{ value: 'code', label: '代码', hint: '侧重代码内容' },
22+
{ value: 'full', label: '全文', hint: '完整提取全部内容' },
23+
];
24+
25+
export function VisionModeSelector({
26+
value,
27+
onChange,
28+
}: {
29+
value: VisionMode;
30+
onChange: (mode: VisionMode) => void;
31+
}) {
32+
return (
33+
<div className="px-4 py-3 border-b border-border/20">
34+
<div className="flex items-center justify-between mb-2">
35+
<span className="text-[12px] font-medium text-text-secondary">视觉提取</span>
36+
<span className="text-[10px] text-text-tertiary">
37+
{MODES.find((m) => m.value === value)?.hint}
38+
</span>
39+
</div>
40+
<div className="flex flex-wrap gap-1.5">
41+
{MODES.map((m) => (
42+
<button
43+
key={m.value}
44+
onClick={() => onChange(m.value)}
45+
className={cn(
46+
'px-2.5 py-1 rounded-kb-sm text-[11px] transition-all active:scale-95',
47+
value === m.value
48+
? 'bg-brand-500/15 text-brand-600 dark:text-brand-400 border border-brand-500/30 font-medium'
49+
: 'text-text-tertiary hover:text-text-secondary border border-transparent hover:bg-bg-tertiary/40',
50+
)}
51+
>
52+
{m.label}
53+
</button>
54+
))}
55+
</div>
56+
</div>
57+
);
58+
}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* recommended (score>0) by default, with a "show all" toggle exposing every
1313
* capturable window as the manual-pick fallback.
1414
*/
15-
import { useState, useRef, useEffect } from 'react';
15+
import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react';
1616
import { Monitor, RefreshCw, Star, ChevronDown, ChevronRight } from 'lucide-react';
1717
import { cn } from '@/lib/utils';
1818
import type { WindowInfo } from '@/lib/capture';
@@ -71,6 +71,14 @@ export function WindowSelectCard({ windows, selected, onSelect, onRefresh, loadi
7171
return () => document.removeEventListener('mousedown', handleDocDown);
7272
}, [open]);
7373

74+
// 点击浮层内空白/非交互区域:立即关闭浮层。
75+
// 浮层打开时会覆盖下方配置区(采集路径/模式等),若点击命中浮层内
76+
// 无交互元素,既不会关闭浮层也不会触达下层按钮——形成无反馈死区,
77+
// 用户感知为“页面无法点击”。此处让“点空白即关闭”,下层配置恢复可点。
78+
const handlePopoverDown = (e: ReactMouseEvent) => {
79+
if (!(e.target as HTMLElement).closest('button')) setOpen(false);
80+
};
81+
7482
const handleToggle = () => {
7583
if (disabled) return;
7684
const next = !open;
@@ -121,7 +129,8 @@ export function WindowSelectCard({ windows, selected, onSelect, onRefresh, loadi
121129

122130
{/* 窗口列表浮层:推荐 + 可展开的全部窗口 */}
123131
{open && (
124-
<div className="absolute left-0 right-0 top-full mt-1 z-20 rounded-kb-md border border-border/40 bg-bg-secondary backdrop-blur-xl shadow-kb-md p-2">
132+
<div onMouseDown={handlePopoverDown}
133+
className="absolute left-0 right-0 top-full mt-1 z-20 rounded-kb-md border border-border/40 bg-bg-secondary backdrop-blur-xl shadow-kb-md p-2">
125134
<div className="flex items-center justify-between px-1 pb-1.5">
126135
<span className="text-[11px] text-text-tertiary">推荐窗口</span>
127136
<button onClick={onRefresh} disabled={loading}

‎client/src/features/classroom/constants.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const STATUS_CONFIG: Record<SessionStatus, { label: string; color: string
9595
export const LANGUAGE_OPTIONS: { value: CaptureSidebarConfig['language']; label: string }[] = [
9696
{ value: 'zh', label: '中文' },
9797
{ value: 'en', label: 'English' },
98-
{ value: 'mixed', label: '多语' },
98+
{ value: 'mixed', label: '中英混说' },
9999
];
100100

101101
export const SUBJECT_OPTIONS = [

0 commit comments

Comments
 (0)