zlwu před 11 měsíci
rodič
revize
d1cc4c8bdf

+ 28 - 57
frontend/src/components/AudioWave.tsx

@@ -32,6 +32,7 @@ export const AudioWave: React.FC<AudioWaveProps> = ({
   const barWidth = 4; // 固定竖条宽度
   const targetBarGap = 2; // 目标竖条间距
   const cornerRadius = 2; // 固定圆角
+  const ANIMATION_INTERVAL = 90; // 动画更新间隔(毫秒),值越大动画越慢
   
   // 根据实际宽度计算需要多少竖条(向上取整确保填满,并确保是奇数)
   const calculatedBarCount = Math.ceil(actualWidth / (barWidth + targetBarGap));
@@ -51,7 +52,6 @@ export const AudioWave: React.FC<AudioWaveProps> = ({
   
   const animationRef = useRef<number | null>(null);
   const lastUpdateRef = useRef<number>(0);
-  const isFadingRef = useRef<boolean>(false); // 是否正在衰减中
   
   // 当 barCount 变化时,调整 barHeights 数组长度
   useEffect(() => {
@@ -74,80 +74,50 @@ export const AudioWave: React.FC<AudioWaveProps> = ({
     return MIN_HEIGHT + Math.random() * (MAX_HEIGHT - MIN_HEIGHT);
   };
 
-  // 更新波形:将新高度推入左侧,右侧数据依次右移
+  // 更新波形:所有竖条在最大高度范围内随机跳动
   const updateWave = () => {
     setBarHeights((prev) => {
-      if (prev.length === 0) return prev;
-      const newHeight = generateRandomHeight();
-      // 新数据推入左侧,移除最右侧数据
       const targetLength = Math.max(1, barCount);
-      return [newHeight, ...prev.slice(0, targetLength - 1)];
+      if (prev.length === 0) {
+        return Array.from({ length: targetLength }, () => STATIC_HEIGHT);
+      }
+      if (prev.length !== targetLength) {
+        return Array.from({ length: targetLength }, (_, idx) => prev[idx] ?? STATIC_HEIGHT);
+      }
+      return prev.map(() => generateRandomHeight());
     });
   };
 
-  // 衰减波形:从左侧推入静态高度的竖条,像滚动一样逐个替换
-  const fadeWave = () => {
+  const resetBarsToStatic = () => {
     setBarHeights((prev) => {
-      if (prev.length === 0) return prev;
-      
-      // 从左侧推入静态高度,右侧数据依次右移(就像正常动画一样)
       const targetLength = Math.max(1, barCount);
-      return [STATIC_HEIGHT, ...prev.slice(0, targetLength - 1)];
+      const needsResize = prev.length !== targetLength;
+      const needsReset =
+        prev.length === 0 ||
+        prev.some((height) => Math.abs(height - STATIC_HEIGHT) > 0.5);
+
+      if (!needsResize && !needsReset) {
+        return prev;
+      }
+
+      return Array.from({ length: targetLength }, () => STATIC_HEIGHT);
     });
   };
 
   // 动画循环
   useEffect(() => {
-    if (!isAnimating && !isFadingRef.current) {
-      // 停止动画时,开始衰减过程
-      isFadingRef.current = true;
-      
-      // 衰减动画:持续推入静态高度竖条,直到所有竖条都变成静态高度
-      const fadeAnimate = (timestamp: number) => {
-        // 每 50ms 推入一个静态高度的竖条(与正常动画频率一致)
-        if (timestamp - lastUpdateRef.current > 50) {
-          // 检查是否所有竖条都已是静态高度
-          setBarHeights((prev) => {
-            const allStatic = prev.every(h => h === STATIC_HEIGHT);
-            if (allStatic) {
-              // 全部恢复,停止衰减动画
-              if (animationRef.current) {
-                cancelAnimationFrame(animationRef.current);
-                animationRef.current = null;
-              }
-              isFadingRef.current = false;
-              return prev;
-            }
-            
-            // 继续推入静态高度
-            return prev;
-          });
-          
-          fadeWave();
-          lastUpdateRef.current = timestamp;
-        }
-        
-        if (isFadingRef.current) {
-          animationRef.current = requestAnimationFrame(fadeAnimate);
-        }
-      };
-      
-      animationRef.current = requestAnimationFrame(fadeAnimate);
-      return;
-    }
-
     if (!isAnimating) {
-      // 如果已经在衰减中,不做处理
+      if (animationRef.current) {
+        cancelAnimationFrame(animationRef.current);
+        animationRef.current = null;
+      }
+      resetBarsToStatic();
       return;
     }
 
-    // 开始正常动画时,重置衰减标志
-    isFadingRef.current = false;
-
-    // 动画帧函数
     const animate = (timestamp: number) => {
-      // 每 50ms 更新一次(模拟音频采样率)
-      if (timestamp - lastUpdateRef.current > 50) {
+      // 每 ANIMATION_INTERVAL 更新一次(模拟音频采样率)
+      if (timestamp - lastUpdateRef.current > ANIMATION_INTERVAL) {
         updateWave();
         lastUpdateRef.current = timestamp;
       }
@@ -159,6 +129,7 @@ export const AudioWave: React.FC<AudioWaveProps> = ({
     return () => {
       if (animationRef.current) {
         cancelAnimationFrame(animationRef.current);
+        animationRef.current = null;
       }
     };
   }, [isAnimating, barCount]);

+ 69 - 8
frontend/src/pages/ChatBotPage.tsx

@@ -45,12 +45,14 @@ export const ChatBotPage = () => {
     updateProgressByDuration,
     setProgress,
     reset,
+    isConnected,
   } = useAppStore();
 
   const recorderRef = useRef<AudioRecorder | null>(null);
   const playerRef = useRef<AudioPlayer | null>(null);
   const wsRef = useRef<WebSocketManager | null>(null);
   const chunkIndexRef = useRef(0);
+  const connectionLostRef = useRef(false);
   const mobileAudioPreparedRef = useRef(false);
   const sendTimeRef = useRef<number>(0);
   // 初始化时检查 localStorage,确保第一次打开时立即显示提示
@@ -327,14 +329,42 @@ export const ChatBotPage = () => {
         const ws = new WebSocketManager();
         const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
         ws.connect(wsUrl, (connected) => {
+          const appState = useAppStore.getState();
+          const wasConnected = appState.isConnected;
+          
           setConnected(connected);
+          
           if (connected) {
             // 记录WebSocket连接建立时间
             performanceLogger.recordWebSocketConnected();
-          } else {
-            setState('error');
-            setError('WebSocket连接断开');
+            
+            if (connectionLostRef.current) {
+              antMessage.success('语音服务已恢复,可以继续对话');
+              connectionLostRef.current = false;
+              recorderRef.current?.stop();
+              playerRef.current?.stop();
+              reset();
+              setError(null);
+            }
+            return;
           }
+          
+          if (!wasConnected) {
+            // 初次连接失败,静默等待自动重连
+            return;
+          }
+          
+          connectionLostRef.current = true;
+          
+          if (appState.currentRequestId) {
+            performanceLogger.clearRequest(appState.currentRequestId, 'websocket_disconnected');
+          }
+          
+          recorderRef.current?.stop();
+          playerRef.current?.stop();
+          
+          antMessage.error('语音服务连接断开,请稍后重试');
+          setError('语音服务连接断开,请稍后重试');
         });
         ws.onMessage('app', handleMessage);
         wsRef.current = ws;
@@ -435,6 +465,11 @@ export const ChatBotPage = () => {
       return;
     }
     
+    if (!isConnected) {
+      antMessage.warning('语音服务正在重连,请稍后再试');
+      return;
+    }
+    
     // 如果正在播放,先停止播放
     if (currentState === 'playing') {
       playerRef.current?.stop();
@@ -502,6 +537,14 @@ export const ChatBotPage = () => {
     if (currentState !== 'recording' || !currentRequestId) {
       return;
     }
+    
+    if (!isConnected) {
+      antMessage.error('语音服务未连接,请重新录音发送');
+      recorderRef.current?.stop();
+      performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
+      reset();
+      return;
+    }
 
     timingRef.current.recordEnd = Date.now();
     sendTimeRef.current = Date.now();
@@ -686,7 +729,7 @@ export const ChatBotPage = () => {
           }}
         >
           <AudioWave
-            isAnimating={currentState === 'recording' || currentState === 'playing'}
+            isAnimating={currentState === 'recording'}
             width={typeof window !== 'undefined' ? window.innerWidth : 375}
             height={layout.waveformHeight}
           />
@@ -758,7 +801,7 @@ export const ChatBotPage = () => {
 
           {/* 录音按钮 - 设计稿:尺寸 96×96,在进度环内居中,左右居中 */}
           <div
-            className="chatbot-record-button absolute cursor-pointer touch-manipulation"
+            className={`chatbot-record-button absolute touch-manipulation ${isConnected ? 'cursor-pointer' : 'cursor-not-allowed'}`}
             onClick={handleStartRecord}
             style={{
               width: `${layout.recordButtonSize}px`,
@@ -769,7 +812,7 @@ export const ChatBotPage = () => {
               left: '50%',
               transform: 'translateX(-50%)',
               borderRadius: '100px',
-              opacity: 1,
+              opacity: isConnected ? 1 : 0.55,
               display: 'flex',
               alignItems: 'center',
               justifyContent: 'center',
@@ -777,9 +820,11 @@ export const ChatBotPage = () => {
               WebkitUserSelect: 'none',
               caretColor: 'transparent',
               outline: 'none',
+              filter: isConnected ? 'none' : 'grayscale(0.25)',
             }}
             contentEditable={false}
             tabIndex={-1}
+            aria-disabled={!isConnected}
           >
           {/* 录音按钮背景 - PNG 图片 */}
           <img
@@ -812,8 +857,24 @@ export const ChatBotPage = () => {
         </div>
       )}
 
+      {!isConnected && currentState !== 'recording' && (
+        <div
+          className="absolute text-white/80 text-center"
+          style={{
+            top: `${layout.progressRingTop - 36}px`,
+            left: '50%',
+            transform: 'translateX(-50%)',
+            zIndex: 25,
+            fontSize: `${Math.max(12, layout.recordButtonSize * 0.18)}px`,
+            textShadow: '0 2px 6px rgba(0,0,0,0.35)',
+          }}
+        >
+          正在重连语音服务...
+        </div>
+      )}
+
       {/* 进度条提示 - 首次有进度时显示 */}
-      {showProgressBarTooltip && (
+      {showProgressBarTooltip && isConnected && (
         <img
           src={tooltipsProgressBar}
           alt="Progress Bar Tooltip"
@@ -829,7 +890,7 @@ export const ChatBotPage = () => {
       )}
 
       {/* Tap to Chat 提示 - 只显示一次 - 等比例缩放 */}
-      {showTapToChat && (
+      {showTapToChat && isConnected && (
         <img
           src={tapToChat}
           alt="Tap to Chat"