File size: 5,091 Bytes
793e953 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 | /**
* RealtimeTranscript 实时转写展示组件
*
* 在录音阶段显示讯飞/阿里实时返回的识别文字。
*/
import React, { useEffect, useRef } from 'react'
import type { RealtimeState, RealtimeSegment } from '../hooks/useRealtimeTranscribe'
import { useTranslation } from '../i18n'
interface Props {
/** 实时转写状态 */
rtState: RealtimeState
/** 是否可见 */
visible: boolean
}
/** 单个说话人分段 */
function SegmentBubble({ seg, isLatest }: { seg: RealtimeSegment; isLatest: boolean }) {
const isVet = seg.speaker === '兽医'
return (
<div className={`flex ${isVet ? 'justify-start' : 'justify-end'} mb-2`}>
<div className={`max-w-[85%] px-3 py-2 rounded-xl text-sm leading-relaxed transition-all duration-300 ${
isVet
? 'bg-medical-50 text-medical-800 rounded-tl-sm'
: 'bg-gray-100 text-gray-700 rounded-tr-sm'
} ${isLatest ? 'shadow-sm ring-1 ring-medical-200' : ''}`}>
<div className="text-[10px] font-semibold uppercase tracking-wide mb-0.5 opacity-50">
{seg.speaker || '...'}
</div>
<div>{seg.text}</div>
</div>
</div>
)
}
/** 纯文本流式展示(说话人未分离时) */
function StreamingText({ text, connected }: { text: string; connected: boolean }) {
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [text])
if (!text && !connected) return null
if (!text && connected) {
return (
<div className="text-center py-6 text-gray-400 text-sm">
<div className="inline-flex items-center gap-2">
<span className="inline-block w-2 h-2 bg-medical-400 rounded-full animate-pulse" />
正在聆听...
</div>
</div>
)
}
return (
<div ref={scrollRef} className="max-h-64 overflow-y-auto scrollbar-thin">
<div className="text-gray-700 text-sm leading-relaxed whitespace-pre-wrap">
{text}
{connected && (
<span className="inline-block w-1.5 h-4 bg-medical-400 ml-0.5 align-middle animate-pulse rounded-sm" />
)}
</div>
</div>
)
}
export default function RealtimeTranscript({ rtState, visible }: Props) {
const { t } = useTranslation()
const { fullText, segments, connected, finalized, error, engine } = rtState
if (!visible) return null
const hasSegments = segments && segments.length > 0
const hasText = fullText && fullText.length > 0
return (
<div className="mt-4 bg-white/90 backdrop-blur-sm border border-gray-200 rounded-2xl p-4 shadow-sm">
{/* 头部 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
finalized ? 'bg-emerald-400' : connected ? 'bg-medical-400 animate-pulse' : 'bg-gray-300'
}`} />
<span className="text-xs font-medium text-gray-500">
{finalized ? '转写完成' : connected ? '实时转写中' : '等待连接...'}
</span>
{engine && (
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded">
{engine.replace('xunfei_', '讯飞·')}
</span>
)}
</div>
{error && (
<span className="text-xs text-red-500 truncate max-w-[200px]">{error}</span>
)}
</div>
{/* 错误提示 */}
{error && !hasText && (
<div className="text-center py-4 text-sm text-red-500">
<p>{error}</p>
<p className="text-xs text-gray-400 mt-1">{t('consultation.realtimeTranscribeError')}</p>
</div>
)}
{/* 分段对话模式(说话人已分离) */}
{hasSegments ? (
<div className="max-h-72 overflow-y-auto scrollbar-thin">
{segments.map((seg, i) => (
<SegmentBubble
key={`${i}-${seg.speaker}-${seg.text.length}`}
seg={seg}
isLatest={i === segments.length - 1 && !finalized}
/>
))}
{connected && !finalized && (
<div className="flex justify-center py-1">
<span className="inline-flex gap-1">
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-medical-300 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
)}
</div>
) : (
/* 流式文本模式 */
<StreamingText text={fullText} connected={connected} />
)}
{/* 空状态 */}
{!hasText && !hasSegments && !connected && !error && (
<div className="text-center py-4 text-xs text-gray-400">
开始说话后,文字将实时显示
</div>
)}
</div>
)
}
|