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' ;
911import { 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
1721interface 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+ }
0 commit comments