zlwu vor 10 Monaten
Ursprung
Commit
8b16fb8da3
3 geänderte Dateien mit 346 neuen und 46 gelöschten Zeilen
  1. 322 40
      frontend/src/pages/ChatBotPage.tsx
  2. 18 0
      frontend/src/store/index.ts
  3. 6 6
      frontend/src/styles/chatbot-fix.css

+ 322 - 40
frontend/src/pages/ChatBotPage.tsx

@@ -262,7 +262,13 @@ export const ChatBotPage = () => {
   const [displayProgress, setDisplayProgress] = useState(0);
   const animationFrameRef = useRef<number>();
   const totalProgressRef = useRef(0);
-  const shouldSkipProgressAnimationRef = useRef(false);
+  // 默认置为 true,避免组件初次挂载或路由返回时尚未完成数据同步就触发历史进度动画。
+  const shouldSkipProgressAnimationRef = useRef(true);
+  // 保存上一次的进度状态,用于计算动画起始值
+  const prevProgressStateRef = useRef<{ progress: number; loopCount: number }>({
+    progress: 0,
+    loopCount: 0,
+  });
 
   const recordingTimerRef = useRef<number | null>(null);
   const recordingDeadlineRef = useRef<number>(0);
@@ -581,38 +587,76 @@ export const ChatBotPage = () => {
 
   // 当能量石开始消失时,设置动画的起点和终点(处理超时自动消失的情况)
   useEffect(() => {
+    const timers: number[] = [];
+    const resetAnimation = (element?: HTMLElement | null) => {
+      if (!element) {
+        return;
+      }
+      element.style.animation = 'none';
+      // 强制重排,确保重新读取起点坐标
+      void element.offsetWidth;
+      element.style.animation = '';
+    };
+
     energyStones.forEach((stone) => {
-      if (stone.fading) {
-        // 使用 setTimeout 确保 DOM 已更新
-        const timer = setTimeout(() => {
-          const container = document.querySelector(`[data-stone-id="${stone.stoneId}"]`) as HTMLElement;
-          if (container) {
-            const rect = container.getBoundingClientRect();
-            const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
-            const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
-            
-            // 计算终点位置(动画形象中心,距离顶部60%位置)
-            const viewportWidth = window.innerWidth;
-            const viewportHeight = window.innerHeight;
-            
-            // 计算终点 top 位置(像素)
-            const endTopPx = Math.min(
-              (452 * Math.min(viewportWidth, 600) / 375),
-              (452 / 812) * viewportHeight
-            );
-            const endLeft = viewportWidth / 2; // 50% 位置(像素)
-            
-            // 设置 CSS 变量,用于动画(使用像素值)
-            container.style.setProperty('--animation-start-left', `${startLeft}px`);
-            container.style.setProperty('--animation-start-top', `${startTop}px`);
-            container.style.setProperty('--animation-end-left', `${endLeft}px`);
-            container.style.setProperty('--animation-end-top', `${endTopPx}px`);
-          }
-        }, 0);
-        
-        return () => clearTimeout(timer);
+      const container = document.querySelector(
+        `[data-stone-id="${stone.stoneId}"]`
+      ) as HTMLElement | null;
+
+      // 非消失状态,移除就绪标记,避免错误启动动画
+      if (!stone.fading) {
+        container?.removeAttribute('data-anim-ready');
+        return;
       }
+
+      // 使用 setTimeout 确保 DOM 已更新
+      const timer = window.setTimeout(() => {
+        const target = document.querySelector(
+          `[data-stone-id="${stone.stoneId}"]`
+        ) as HTMLElement | null;
+
+        if (!target) {
+          return;
+        }
+
+        const rect = target.getBoundingClientRect();
+        const startLeft = rect.left + rect.width / 2; // 中心点 x 坐标(像素)
+        const startTop = rect.top + rect.height / 2; // 中心点 y 坐标(像素)
+
+        // 计算终点位置(动画形象中心,距离顶部60%位置)
+        const viewportWidth = window.innerWidth;
+        const viewportHeight = window.innerHeight;
+
+        // 计算终点 top 位置(像素)
+        const endTopPx = Math.min(
+          (452 * Math.min(viewportWidth, 600) / 375),
+          (452 / 812) * viewportHeight
+        );
+        const endLeft = viewportWidth / 2; // 50% 位置(像素)
+
+        // 设置 CSS 变量,用于动画(使用像素值)
+        target.style.setProperty('--animation-start-left', `${startLeft}px`);
+        target.style.setProperty('--animation-start-top', `${startTop}px`);
+        target.style.setProperty('--animation-end-left', `${endLeft}px`);
+        target.style.setProperty('--animation-end-top', `${endTopPx}px`);
+
+        // 标记动画就绪,允许 CSS 启动
+        target.setAttribute('data-anim-ready', 'true');
+
+        // 重新启动动画,确保使用更新后的起点
+        resetAnimation(target);
+        resetAnimation(target.querySelector('.energy-stone'));
+        resetAnimation(target.querySelector('.energy-stone-hint'));
+      }, 0);
+
+      timers.push(timer);
     });
+
+    return () => {
+      timers.forEach((timer) => {
+        window.clearTimeout(timer);
+      });
+    };
   }, [energyStones]);
 
   // 能量石展示区域:宽高 & 位置按设计稿 375×812 固定,以 px2vw/px2vh/px2vmin 适配
@@ -668,16 +712,132 @@ export const ChatBotPage = () => {
   // 进度动画效果(支持多圈连续动画)
   useEffect(() => {
     const targetTotal = progressLoopCount * 100 + conversationProgress;
-    const startTotal = totalProgressRef.current;
-    const diff = targetTotal - startTotal;
+    
+    // 🔍 日志:记录 useEffect 触发时的状态
+    console.log('[Progress] [useEffect] 触发:', {
+      conversationProgress,
+      progressLoopCount,
+      targetTotal,
+      prevProgressStateRef: { ...prevProgressStateRef.current },
+      totalProgressRef: totalProgressRef.current,
+      displayProgress,
+      shouldSkipAnimation: shouldSkipProgressAnimationRef.current,
+      hasActiveAnimation: !!animationFrameRef.current,
+    });
 
+    // 🔧 如果 shouldSkipProgressAnimation 为 true,说明处于初始化/同步阶段
+    // 直接同步 ref 并返回,避免播放历史动画。标志由后续用户动作(如 handleSend)再置为 false。
     if (shouldSkipProgressAnimationRef.current) {
-      shouldSkipProgressAnimationRef.current = false;
+      console.log('[Progress] [useEffect] 初始化/同步阶段,跳过动画并同步 ref');
+      if (animationFrameRef.current) {
+        cancelAnimationFrame(animationFrameRef.current);
+        animationFrameRef.current = undefined;
+      }
+      prevProgressStateRef.current = {
+        progress: conversationProgress,
+        loopCount: progressLoopCount,
+      };
       totalProgressRef.current = targetTotal;
       const normalized = ((targetTotal % 100) + 100) % 100;
       setDisplayProgress(normalized);
       return;
     }
+    
+    // 使用上一次保存的状态值作为起始值(确保动画起始值正确)
+    // 这样可以避免 displayProgress 异步更新导致的时序问题
+    let prevTotal = prevProgressStateRef.current.loopCount * 100 + prevProgressStateRef.current.progress;
+    
+    // 立即保存当前状态到 prevProgressStateRef(供下次动画使用)
+    // 这样即使本次动画还没完成,新的状态更新也能正确工作
+    const currentState = {
+      progress: conversationProgress,
+      loopCount: progressLoopCount,
+    };
+    
+    // 如果 prevTotal 是初始值 0,但目标值已经大于 0,说明是首次使用或状态被重置
+    // 此时应该使用 totalProgressRef.current 或 displayProgress 作为起始值
+    // 但如果 shouldSkipProgressAnimation 为 true,说明是初始化,应该直接使用目标值作为起始值
+    if (prevTotal === 0 && targetTotal > 0) {
+      if (shouldSkipProgressAnimationRef.current) {
+        // 初始化场景:直接使用目标值作为起始值,避免播放动画
+        console.log('[Progress] [useEffect] 初始化场景,使用目标值作为起始值,跳过动画');
+        prevTotal = targetTotal;
+        prevProgressStateRef.current = currentState;
+        totalProgressRef.current = targetTotal;
+      } else {
+        console.log('[Progress] [useEffect] 检测到初始值情况,使用 displayProgress 或 totalProgressRef');
+        // 如果 totalProgressRef 有值,使用它;否则使用 displayProgress
+        if (totalProgressRef.current > 0) {
+          prevTotal = totalProgressRef.current;
+          console.log('[Progress] [useEffect] 使用 totalProgressRef:', totalProgressRef.current);
+        } else {
+          prevTotal = displayProgress;
+          console.log('[Progress] [useEffect] 使用 displayProgress:', displayProgress);
+        }
+        // 更新 prevProgressStateRef 为当前显示的状态
+        prevProgressStateRef.current = {
+          progress: displayProgress,
+          loopCount: Math.floor(totalProgressRef.current / 100),
+        };
+        console.log('[Progress] [useEffect] 更新 prevProgressStateRef 为:', prevProgressStateRef.current);
+      }
+    }
+    
+    // 始终使用上一次保存的状态作为起始值,而不是 totalProgressRef.current
+    // 因为 totalProgressRef.current 可能在动画过程中,不准确
+    let startTotal = prevTotal;
+    
+    // 如果 totalProgressRef 与上一次状态不一致,同步它
+    // 这样可以确保 totalProgressRef 与起始值一致,避免动画跳变
+    if (!shouldSkipProgressAnimationRef.current) {
+      const diffFromPrev = Math.abs(totalProgressRef.current - prevTotal);
+      
+      console.log('[Progress] [useEffect] 检查 totalProgressRef 同步:', {
+        diffFromPrev,
+        totalProgressRef: totalProgressRef.current,
+        prevTotal,
+        needSync: diffFromPrev > 1,
+      });
+      
+      // 如果差异较大(超过1%),说明可能有动画在进行,需要取消它并同步起始值
+      if (diffFromPrev > 1) {
+        console.log('[Progress] [useEffect] 取消旧动画并同步 totalProgressRef');
+        // 取消可能正在进行的动画
+        if (animationFrameRef.current) {
+          cancelAnimationFrame(animationFrameRef.current);
+          animationFrameRef.current = undefined;
+        }
+        // 同步 totalProgressRef 到起始值
+        totalProgressRef.current = startTotal;
+      }
+    }
+    
+    // 立即更新 prevProgressStateRef 为当前状态(供下次动画使用)
+    // 注意:这里保存的是本次动画的目标值,下次动画会使用这个值作为起始值
+    prevProgressStateRef.current = currentState;
+    
+    const diff = targetTotal - startTotal;
+    
+    console.log('[Progress] [useEffect] 计算动画参数:', {
+      startTotal,
+      targetTotal,
+      diff,
+      willAnimate: Math.abs(diff) >= 0.01 && !shouldSkipProgressAnimationRef.current,
+    });
+
+    // 如果 diff 为 0 或非常小,直接同步,不播放动画
+    if (Math.abs(diff) < 0.01) {
+      console.log('[Progress] [useEffect] 跳过动画(diff 太小):', {
+        diff,
+        startTotal,
+        targetTotal,
+      });
+      totalProgressRef.current = targetTotal;
+      const normalized = ((targetTotal % 100) + 100) % 100;
+      setDisplayProgress(normalized);
+      // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
+      return;
+    }
 
     // 处理整数圈:如果 diff 接近 0 且是整数圈,播放完整一圈动画
     let actualDiff = diff;
@@ -695,6 +855,7 @@ export const ChatBotPage = () => {
         totalProgressRef.current = targetTotal;
         const normalized = ((targetTotal % 100) + 100) % 100;
         setDisplayProgress(normalized);
+        // prevProgressStateRef 已经在上面更新了,这里不需要再次更新
         return;
       }
     }
@@ -704,11 +865,22 @@ export const ChatBotPage = () => {
     const duration = Math.max(400, Math.min(2400, Math.abs(actualDiff) * durationPerPercent));
     const startTime = performance.now();
 
+    console.log('[Progress] [useEffect] 开始动画:', {
+      startTotal,
+      targetTotal,
+      actualDiff,
+      duration,
+      startTime,
+    });
+
     if (animationFrameRef.current) {
+      console.log('[Progress] [useEffect] 取消旧动画');
       cancelAnimationFrame(animationFrameRef.current);
     }
 
+    let animationFrameCount = 0;
     const animate = (currentTime: number) => {
+      animationFrameCount++;
       const elapsed = currentTime - startTime;
       const progress = Math.min(elapsed / duration, 1);
 
@@ -720,13 +892,30 @@ export const ChatBotPage = () => {
       const normalized = ((currentTotal % 100) + 100) % 100;
       setDisplayProgress(normalized);
 
+      // 🔍 日志:每10帧记录一次动画进度
+      if (animationFrameCount % 10 === 0 || progress >= 1) {
+        console.log('[Progress] [animate] 动画进度:', {
+          frame: animationFrameCount,
+          progress: (progress * 100).toFixed(2) + '%',
+          currentTotal: currentTotal.toFixed(2),
+          normalized: normalized.toFixed(2),
+          elapsed: elapsed.toFixed(2) + 'ms',
+        });
+      }
+
       if (progress < 1) {
         animationFrameRef.current = requestAnimationFrame(animate);
       } else {
         // 动画完成后,同步到实际的目标值
+        console.log('[Progress] [animate] 动画完成:', {
+          finalTotal: targetTotal,
+          finalNormalized: ((targetTotal % 100) + 100) % 100,
+          totalFrames: animationFrameCount,
+        });
         totalProgressRef.current = targetTotal;
         const finalNormalized = ((targetTotal % 100) + 100) % 100;
         setDisplayProgress(finalNormalized);
+        // prevProgressStateRef 已经在 useEffect 开始时更新了,这里不需要再次更新
       }
     };
 
@@ -1016,6 +1205,11 @@ export const ChatBotPage = () => {
 
   // 初始化
   useEffect(() => {
+    // 🔧 立即设置 shouldSkipProgressAnimation = true,避免在异步加载完成前触发动画
+    // 这可以防止从其他页面返回时播放历史动画
+    shouldSkipProgressAnimationRef.current = true;
+    console.log('[Progress] [初始化] 组件挂载,设置 shouldSkipProgressAnimation = true');
+    
     // 记录用户当前所在页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, 'chatbot');
     
@@ -1087,8 +1281,23 @@ export const ChatBotPage = () => {
             }
             const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
             const initialProgress = totalProgressPercent % 100;
+            const initialLoopCount = Math.floor(totalProgressPercent / 100);
+            
+            console.log('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (Promise.all)', {
+              totalDurationMs,
+              totalProgressPercent,
+              initialProgress,
+              initialLoopCount,
+            });
+            // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
+            prevProgressStateRef.current = {
+              progress: initialProgress,
+              loopCount: initialLoopCount,
+            };
+            totalProgressRef.current = initialLoopCount * 100 + initialProgress;
             shouldSkipProgressAnimationRef.current = true;
-            setProgress(initialProgress, 0);
+            // 设置正确的圈数,而不是重置为 0
+            setProgress(initialProgress, initialLoopCount);
           }
         }
         
@@ -1272,12 +1481,26 @@ export const ChatBotPage = () => {
                 if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
                   unlockAvatarC();
                 }
-                // 只显示非整数圈的进度,不转多圈
+                // 计算总进度百分比和圈数
                 const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
                 const initialProgress = totalProgressPercent % 100;
-                // 重置圈数为 0,只显示当前周期的进度
+                const initialLoopCount = Math.floor(totalProgressPercent / 100);
+                
+                console.log('[Progress] [初始化] 设置 shouldSkipProgressAnimation = true (init)', {
+                  totalDurationMs,
+                  totalProgressPercent,
+                  initialProgress,
+                  initialLoopCount,
+                });
+                // 在设置进度之前,先同步 prevProgressStateRef 和 totalProgressRef,避免动画计算错误
+                prevProgressStateRef.current = {
+                  progress: initialProgress,
+                  loopCount: initialLoopCount,
+                };
+                totalProgressRef.current = initialLoopCount * 100 + initialProgress;
                 shouldSkipProgressAnimationRef.current = true;
-                setProgress(initialProgress, 0);
+                // 设置正确的圈数,而不是重置为 0
+                setProgress(initialProgress, initialLoopCount);
               }
             }
           } catch (error) {
@@ -1478,9 +1701,39 @@ export const ChatBotPage = () => {
           // 根据总时长计算进度条位置(只显示非整数圈的进度)
           const totalProgressPercent = (message.totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
           const progress = totalProgressPercent % 100;
-          // 跳过动画,直接同步到后端返回的值,重置圈数为 0
-          shouldSkipProgressAnimationRef.current = true;
-          setProgress(progress, 0);
+          
+          // 🔍 检查当前状态,如果用户刚刚更新了进度,不要覆盖用户的更新
+          const currentState = useAppStore.getState();
+          const currentTotal = currentState.progressLoopCount * 100 + currentState.conversationProgress;
+          const newTotal = Math.floor(totalProgressPercent / 100) * 100 + progress;
+          
+          console.log('[Progress] [user_level] 收到后端同步请求', {
+            totalDurationMs: message.totalDurationMs,
+            progress,
+            currentTotal,
+            newTotal,
+            requestId: requestId?.substring(0, 8),
+            shouldSync: Math.abs(currentTotal - newTotal) > 0.1, // 只有差异较大时才同步
+          });
+          
+          // 只有在差异较大时才同步(避免覆盖用户刚刚的更新)
+          // 并且只在初始化时跳过动画,用户主动更新后不应该跳过动画
+          if (Math.abs(currentTotal - newTotal) > 0.1) {
+            // 计算应该保持的圈数(基于总时长)
+            const targetLoopCount = Math.floor(totalProgressPercent / 100);
+            // 只在初始化时跳过动画(如果当前进度接近0)
+            const isInitialSync = currentTotal < 1;
+            if (isInitialSync) {
+              console.log('[Progress] [user_level] 初始化同步,跳过动画');
+              shouldSkipProgressAnimationRef.current = true;
+            } else {
+              console.log('[Progress] [user_level] 同步进度,保持圈数:', targetLoopCount);
+              // 保持当前的圈数,不重置为0
+              setProgress(progress, targetLoopCount);
+            }
+          } else {
+            console.log('[Progress] [user_level] 差异太小,跳过同步');
+          }
         }
         break;
 
@@ -1823,8 +2076,37 @@ export const ChatBotPage = () => {
     
     // 更新进度条(基于说话时长,30秒=100%)
     if (speechDuration > 0) {
+      // 🔍 日志:记录更新前的状态
+      const beforeState = useAppStore.getState();
+      console.log('[Progress] [handleSend] 准备更新进度:', {
+        speechDurationMs: speechDuration,
+        beforeProgress: beforeState.conversationProgress,
+        beforeLoopCount: beforeState.progressLoopCount,
+        beforeTotal: beforeState.progressLoopCount * 100 + beforeState.conversationProgress,
+        prevProgressStateRef: { ...prevProgressStateRef.current },
+        totalProgressRef: totalProgressRef.current,
+        displayProgress: displayProgress,
+        shouldSkipAnimation: shouldSkipProgressAnimationRef.current,
+      });
+      
+      // 🔧 确保 shouldSkipProgressAnimation 在用户主动更新时是 false
+      // 初始化完成后,用户主动更新进度时应该播放动画
+      if (shouldSkipProgressAnimationRef.current) {
+        console.log('[Progress] [handleSend] 检测到 shouldSkipProgressAnimation 为 true,重置为 false');
+        shouldSkipProgressAnimationRef.current = false;
+      }
+      
       updateProgressByDuration(speechDuration);
       registerSpeechDuration(speechDuration);
+      
+      // 🔍 日志:记录更新后的状态
+      const afterState = useAppStore.getState();
+      console.log('[Progress] [handleSend] 更新后状态:', {
+        afterProgress: afterState.conversationProgress,
+        afterLoopCount: afterState.progressLoopCount,
+        afterTotal: afterState.progressLoopCount * 100 + afterState.conversationProgress,
+        expectedIncrement: (speechDuration / 30000) * 100,
+      });
     }
 
     recorderRef.current?.stop();

+ 18 - 0
frontend/src/store/index.ts

@@ -67,17 +67,35 @@ export const useAppStore = create<AppState>((set) => ({
     // 计算新的进度值
     const newProgress = state.conversationProgress + progressIncrement;
     
+    // 🔍 日志:记录状态更新
+    console.log('[Progress] [store] updateProgressByDuration:', {
+      durationMs,
+      progressIncrement: progressIncrement.toFixed(2),
+      currentProgress: state.conversationProgress,
+      currentLoopCount: state.progressLoopCount,
+      newProgress: newProgress.toFixed(2),
+      willLoop: newProgress >= 100,
+    });
+    
     // 如果超过100%,转一圈后再继续
     // 例如:当前90%,说话31秒,则:90% + 103.33% = 193.33% → 转一圈后剩余 93.33%
     if (newProgress >= 100) {
       const cycles = Math.floor(newProgress / 100); // 转了几圈
       const remainder = newProgress % 100; // 剩余进度
+      console.log('[Progress] [store] 转圈:', {
+        cycles,
+        remainder: remainder.toFixed(2),
+        newLoopCount: state.progressLoopCount + cycles,
+      });
       return {
         conversationProgress: remainder,
         progressLoopCount: state.progressLoopCount + cycles,
       };
     }
     
+    console.log('[Progress] [store] 更新进度(不转圈):', {
+      newProgress: newProgress.toFixed(2),
+    });
     return { conversationProgress: newProgress };
   }),
   setProgress: (progress, loopCount) =>

+ 6 - 6
frontend/src/styles/chatbot-fix.css

@@ -75,7 +75,7 @@ body[data-too-small="true"] .chatbot-bg,
   /* 旧的规则,保留兼容性 */
 }
 
-.energy-stone-container[data-fading='true'][data-stone-id] {
+.energy-stone-container[data-fading='true'][data-anim-ready='true'][data-stone-id] {
   /* 移除浮动动画,应用移动动画 */
   animation: energyStoneMoveToAvatar 1s ease-in-out forwards !important;
   /* 确保 transform 能够正确应用 */
@@ -87,11 +87,11 @@ body[data-too-small="true"] .chatbot-bg,
 }
 
 /* 正式能量石动画时,能量石和光晕同步缩小 */
-.energy-stone-container[data-fading='true'][data-stone-id] .energy-stone {
+.energy-stone-container[data-fading='true'][data-anim-ready='true'][data-stone-id] .energy-stone {
   animation: energyStoneShrink 1s ease-in-out forwards !important;
 }
 
-.energy-stone-container[data-fading='true'][data-stone-id] .energy-stone-hint {
+.energy-stone-container[data-fading='true'][data-anim-ready='true'][data-stone-id] .energy-stone-hint {
   /* 禁用呼吸灯动画,应用缩小动画 */
   animation: energyStoneHintShrink 1s ease-in-out forwards !important;
 }
@@ -152,7 +152,7 @@ body[data-too-small="true"] .chatbot-bg,
 }
 
 /* 能量石容器在消失动画时 */
-div[data-fading='true'] {
+div[data-fading='true'][data-anim-ready='true'] {
   position: fixed !important;
   animation: energyStoneFadeToAvatar 1.5s ease-in-out forwards !important;
   z-index: 10000 !important;
@@ -160,12 +160,12 @@ div[data-fading='true'] {
 }
 
 /* 动画中的能量石,禁用浮动动画 */
-div[data-fading='true'] .energy-stone {
+div[data-fading='true'][data-anim-ready='true'] .energy-stone {
   animation: none !important;
 }
 
 /* 动画中的能量石,禁用光晕呼吸灯动画,让光晕跟随能量石一起移动和缩小 */
-div[data-fading='true'] .energy-stone-hint {
+div[data-fading='true'][data-anim-ready='true'] .energy-stone-hint {
   animation: none !important;
   /* 光晕跟随能量石一起缩小和移动,保持居中 */
   transform: translate(-50%, -50%) !important;