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>
  )
}