zlwu 11 ay önce
ebeveyn
işleme
310aa2018c

+ 97 - 27
frontend/src/pages/ChatBotPage.tsx

@@ -81,7 +81,12 @@ export const ChatBotPage = () => {
   const [showCancelTooltip, setShowCancelTooltip] = useState(false);
   const [showSendTooltip, setShowSendTooltip] = useState(false);
   const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
+  const [hasShownProgressBarTooltip, setHasShownProgressBarTooltip] = useState(() => {
+    const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
+    return stored === 'true';
+  });
   const progressBarTooltipTimerRef = useRef<number | null>(null);
+  const progressBarTooltipRef = useRef<HTMLImageElement | null>(null);
   const [isAvatarCUnlocked, setIsAvatarCUnlocked] = useState(() => {
     const stored = getStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED);
     return stored === 'true';
@@ -125,6 +130,7 @@ export const ChatBotPage = () => {
     const initialLayout = calculateLayout();
     return initialLayout;
   });
+  const [isAvatarGifReady, setIsAvatarGifReady] = useState(false);
   
   const timingRef = useRef({
     recordStart: 0,
@@ -156,6 +162,38 @@ export const ChatBotPage = () => {
     [unlockAvatarC]
   );
 
+  useEffect(() => {
+    let isCanceled = false;
+    const preloader = new Image();
+
+    const handleLoad = () => {
+      if (!isCanceled) {
+        setIsAvatarGifReady(true);
+      }
+    };
+
+    const handleError = () => {
+      if (!isCanceled) {
+        setIsAvatarGifReady(false);
+      }
+    };
+
+    preloader.addEventListener('load', handleLoad);
+    preloader.addEventListener('error', handleError);
+    preloader.decoding = 'sync';
+    preloader.src = animaZoneAvatarGif;
+
+    if (preloader.complete) {
+      handleLoad();
+    }
+
+    return () => {
+      isCanceled = true;
+      preloader.removeEventListener('load', handleLoad);
+      preloader.removeEventListener('error', handleError);
+    };
+  }, []);
+
   const emitStoneEvent = useCallback(
     (stoneId: string, event: 'click' | 'timeout', position: EnergyStoneEventPayload) => {
       if (!wsRef.current) {
@@ -372,36 +410,70 @@ export const ChatBotPage = () => {
     }
   }, [currentState]);
 
-  // 监听进度条变化,首次有进度时显示进度条提示(2秒后自动隐藏)
-  // 🔧 临时修改:为了UI调试,进度更新时始终显示提示
+  const dismissProgressBarTooltip = useCallback(() => {
+    if (progressBarTooltipTimerRef.current) {
+      clearTimeout(progressBarTooltipTimerRef.current);
+      progressBarTooltipTimerRef.current = null;
+    }
+    if (progressBarTooltipRef.current) {
+      progressBarTooltipRef.current.style.opacity = '0';
+      progressBarTooltipRef.current.style.pointerEvents = 'none';
+      progressBarTooltipRef.current.style.display = 'none';
+    }
+    setShowProgressBarTooltip(false);
+  }, []);
+
   useEffect(() => {
-    // const hasShownProgressBar = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
-    
-    // // 如果已经显示过,不再处理
-    // if (hasShownProgressBar) {
-    //   return;
-    // }
-    
-    // 当进度条第一次大于0时显示提示(只显示一次)
-    const hasShownProgressBarTooltip = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
-    if (displayProgress > 0 && !showProgressBarTooltip && !hasShownProgressBarTooltip) {
+    if (showProgressBarTooltip && progressBarTooltipRef.current) {
+      progressBarTooltipRef.current.style.display = 'block';
+      progressBarTooltipRef.current.style.opacity = '1';
+      progressBarTooltipRef.current.style.pointerEvents = 'none';
+    }
+  }, [showProgressBarTooltip]);
+
+  // 监听进度条变化,首次有进度时显示进度条提示(只出现一次)
+  useEffect(() => {
+    if (
+      displayProgress > 0 &&
+      !hasShownProgressBarTooltip &&
+      isConnected &&
+      currentState !== 'recording'
+    ) {
       setShowProgressBarTooltip(true);
       setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
-      
-      // 2秒后自动隐藏
-      progressBarTooltipTimerRef.current = window.setTimeout(() => {
-        setShowProgressBarTooltip(false);
+      setHasShownProgressBarTooltip(true);
+    }
+  }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
+
+  // 进度条提示显示后自动隐藏(2 秒)
+  useEffect(() => {
+    if (!showProgressBarTooltip) {
+      if (progressBarTooltipTimerRef.current) {
+        clearTimeout(progressBarTooltipTimerRef.current);
         progressBarTooltipTimerRef.current = null;
-      }, 2000);
+      }
+      return;
     }
-    
+
+    progressBarTooltipTimerRef.current = window.setTimeout(() => {
+      setShowProgressBarTooltip(false);
+      progressBarTooltipTimerRef.current = null;
+    }, 2000);
+
     return () => {
       if (progressBarTooltipTimerRef.current) {
         clearTimeout(progressBarTooltipTimerRef.current);
         progressBarTooltipTimerRef.current = null;
       }
     };
-  }, [displayProgress, showProgressBarTooltip]);
+  }, [showProgressBarTooltip]);
+
+  // 录音状态下强制隐藏进度条提示,避免在用户开始录音后残留
+  useEffect(() => {
+    if (currentState === 'recording') {
+      dismissProgressBarTooltip();
+    }
+  }, [currentState, dismissProgressBarTooltip]);
 
   // 监听屏幕尺寸变化
   useEffect(() => {
@@ -650,13 +722,7 @@ export const ChatBotPage = () => {
     }
 
     // 点击录音按钮时,隐藏进度条提示
-    if (showProgressBarTooltip) {
-      setShowProgressBarTooltip(false);
-      if (progressBarTooltipTimerRef.current) {
-        clearTimeout(progressBarTooltipTimerRef.current);
-        progressBarTooltipTimerRef.current = null;
-      }
-    }
+    dismissProgressBarTooltip();
 
     if (isMobileDevice() && !mobileAudioPreparedRef.current) {
       try {
@@ -791,7 +857,8 @@ export const ChatBotPage = () => {
   const avatarHintSize = Math.round(layout.avatarCSize * 1.8);
 
   const isAvatarAnimating = currentState === 'playing';
-  const currentAvatarSrc = isAvatarAnimating ? animaZoneAvatarGif : animaZoneAvatar;
+  const currentAvatarSrc =
+    isAvatarAnimating && isAvatarGifReady ? animaZoneAvatarGif : animaZoneAvatar;
   const currentAvatarAlt = isAvatarAnimating ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
   const energyStoneAssets: Record<'mission' | 'conversation', string> = {
     mission: missionEnergyStone,
@@ -958,6 +1025,8 @@ export const ChatBotPage = () => {
         <img
           src={currentAvatarSrc}
           alt={currentAvatarAlt}
+          loading="eager"
+          decoding="sync"
           style={{
             width: '100%',
             height: '100%',
@@ -1096,6 +1165,7 @@ export const ChatBotPage = () => {
             {/* 进度条提示 - 相对于进度环定位 - 首次有进度时显示 */}
             {showProgressBarTooltip && isConnected && (
               <img
+                ref={progressBarTooltipRef}
                 src={tooltipsProgressBar}
                 alt="Progress Bar Tooltip"
                 className="chatbot-tooltips absolute pointer-events-none"

+ 79 - 2
frontend/src/pages/WelcomePage.tsx

@@ -22,8 +22,10 @@ export const WelcomePage = () => {
   const loadingStartTimeRef = useRef<number | null>(null); // 加载开始时间
   const userInteractionRef = useRef(false); // 用户是否已交互(用于确保声音播放)
   const viewStateRef = useRef<WelcomeViewState>('welcome'); // 用于在回调中访问最新状态
+  const hasNavigatedRef = useRef(false); // 是否已经跳转到 /naming,防止重复执行
   const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); // 检查定时器
   const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 兜底定时器
+  const [showSkipButton, setShowSkipButton] = useState(false); // 是否展示“跳过动画”按钮(只供用户手动跳转)
 
   useEffect(() => {
     // 记录用户当前所在页面
@@ -316,15 +318,63 @@ export const WelcomePage = () => {
         videoElement.style.height = '100%';
         videoElement.style.zIndex = '2';
 
+        // 用户可控的“跳过动画”按钮:进入播放阶段一段时间后才出现,避免误触
+        setShowSkipButton(false);
+        const skipTimer = setTimeout(() => {
+          if (!hasNavigatedRef.current && viewStateRef.current === 'playing') {
+            console.log('[WelcomePage] 播放时间较长,展示跳过动画按钮');
+            setShowSkipButton(true);
+          }
+        }, 20000); // 播放 20 秒后才出现“跳过动画”
+        (player as any)._skipTimer = skipTimer;
+
+        // 🚀 统一的安全跳转函数,避免多次 navigate
+        const safeNavigateToNaming = () => {
+          if (hasNavigatedRef.current) {
+            return;
+          }
+          hasNavigatedRef.current = true;
+          console.log('[WelcomePage] 触发跳转到 /naming');
+          if (checkIntervalRef.current) {
+            clearInterval(checkIntervalRef.current);
+            checkIntervalRef.current = null;
+          }
+          if (fallbackTimerRef.current) {
+            clearTimeout(fallbackTimerRef.current);
+            fallbackTimerRef.current = null;
+          }
+          navigate('/naming');
+        };
+
         // 绑定视频播放结束事件(原生事件,确保可靠)
         const handleVideoEnded = () => {
-          console.log('[WelcomePage] 视频播放完成,跳转到 /naming');
-          navigate('/naming');
+          console.log('[WelcomePage] 视频播放完成(video ended),准备跳转');
+          safeNavigateToNaming();
         };
         videoElement.addEventListener('ended', handleVideoEnded);
         // 保存处理器以便清理
         (player as any)._videoEndedHandler = handleVideoEnded;
 
+        // 🔁 兼容部分浏览器不触发 ended 的情况:监听播放进度,接近结尾时跳转
+        const handleTimeUpdate = () => {
+          try {
+            const duration = videoElement.duration;
+            const currentTime = videoElement.currentTime;
+            if (!duration || Number.isNaN(duration) || duration <= 0) {
+              return;
+            }
+            const progress = currentTime / duration;
+            if (progress >= 0.995) {
+              console.log('[WelcomePage] 视频已播放到 99.5% 以上,触发兜底跳转');
+              safeNavigateToNaming();
+            }
+          } catch (error) {
+            console.warn('[WelcomePage] timeupdate 处理异常:', error);
+          }
+        };
+        videoElement.addEventListener('timeupdate', handleTimeUpdate);
+        (player as any)._timeUpdateHandler = handleTimeUpdate;
+
         // 确保视频未静音(用户已交互,应该可以播放声音)
         player.muted(false);
         videoElement.muted = false;
@@ -380,10 +430,20 @@ export const WelcomePage = () => {
           const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
           if (videoElement) {
             const videoEndedHandler = (player as any)._videoEndedHandler;
+            const timeUpdateHandler = (player as any)._timeUpdateHandler;
             if (videoEndedHandler) {
               videoElement.removeEventListener('ended', videoEndedHandler);
               (player as any)._videoEndedHandler = null;
             }
+            if (timeUpdateHandler) {
+              videoElement.removeEventListener('timeupdate', timeUpdateHandler);
+              (player as any)._timeUpdateHandler = null;
+            }
+          }
+          const skipTimer = (player as any)._skipTimer;
+          if (skipTimer) {
+            clearTimeout(skipTimer);
+            (player as any)._skipTimer = null;
           }
         }
       };
@@ -424,6 +484,7 @@ export const WelcomePage = () => {
     // 切换到加载状态
     setViewState('loading');
     setIsLoading(true);
+    setShowSkipButton(false);
     loadingStartTimeRef.current = Date.now();
 
     // 🔑 关键修复:在用户交互的直接调用链中立即尝试播放
@@ -577,6 +638,22 @@ export const WelcomePage = () => {
             您的浏览器不支持HTML5视频,请升级浏览器。
           </p>
         </video>
+
+        {/* 用户可控的跳过按钮:只在播放阶段且出现卡顿/播放时间较长时显示 */}
+        {viewState === 'playing' && showSkipButton && !hasNavigatedRef.current && (
+          <button
+            className="video-skip-button"
+            type="button"
+            onClick={() => {
+              if (!hasNavigatedRef.current) {
+                hasNavigatedRef.current = true;
+                navigate('/naming');
+              }
+            }}
+          >
+            跳过动画,进入命名
+          </button>
+        )}
       </div>
     </div>
   );