zlwu 11 mesiacov pred
rodič
commit
90929ac56f

+ 6 - 0
backend/main.py

@@ -150,6 +150,8 @@ class UserUIStateResponse(BaseModel):
     has_shown_cancel_tooltip: bool = False
     has_shown_send_tooltip: bool = False
     has_shown_progress_bar_tooltip: bool = False
+    has_shown_mission_stone_modal: bool = False
+    has_shown_conversation_stone_modal: bool = False
     avatar_c_unlocked: bool = False
 
 
@@ -162,6 +164,8 @@ class UpdateUserUIStateRequest(BaseModel):
     has_shown_cancel_tooltip: Optional[bool] = None
     has_shown_send_tooltip: Optional[bool] = None
     has_shown_progress_bar_tooltip: Optional[bool] = None
+    has_shown_mission_stone_modal: Optional[bool] = None
+    has_shown_conversation_stone_modal: Optional[bool] = None
     avatar_c_unlocked: Optional[bool] = None
 
 
@@ -532,6 +536,8 @@ async def update_user_ui_state(
             has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
             has_shown_send_tooltip=request.has_shown_send_tooltip,
             has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
+            has_shown_mission_stone_modal=request.has_shown_mission_stone_modal,
+            has_shown_conversation_stone_modal=request.has_shown_conversation_stone_modal,
             avatar_c_unlocked=request.avatar_c_unlocked,
         )
         if not updated:

+ 4 - 0
backend/models_db.py

@@ -263,6 +263,8 @@ class UserUIState(Base):
     has_shown_cancel_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过取消按钮提示")
     has_shown_send_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过发送按钮提示")
     has_shown_progress_bar_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过进度条提示")
+    has_shown_mission_stone_modal = Column(Boolean, default=False, nullable=False, comment="是否已展示过任务能量石弹窗")
+    has_shown_conversation_stone_modal = Column(Boolean, default=False, nullable=False, comment="是否已展示过对话能量石弹窗")
 
     # Avatar-C 解锁状态
     avatar_c_unlocked = Column(Boolean, default=False, nullable=False, comment="是否已解锁 Avatar-C")
@@ -284,6 +286,8 @@ class UserUIState(Base):
             "has_shown_cancel_tooltip": self.has_shown_cancel_tooltip,
             "has_shown_send_tooltip": self.has_shown_send_tooltip,
             "has_shown_progress_bar_tooltip": self.has_shown_progress_bar_tooltip,
+            "has_shown_mission_stone_modal": self.has_shown_mission_stone_modal,
+            "has_shown_conversation_stone_modal": self.has_shown_conversation_stone_modal,
             "avatar_c_unlocked": self.avatar_c_unlocked,
             "created_at": self.created_at.isoformat() if self.created_at else None,
             "updated_at": self.updated_at.isoformat() if self.updated_at else None,

+ 10 - 0
backend/services/user_service.py

@@ -333,6 +333,8 @@ class UserService:
         has_shown_cancel_tooltip: Optional[bool] = None,
         has_shown_send_tooltip: Optional[bool] = None,
         has_shown_progress_bar_tooltip: Optional[bool] = None,
+        has_shown_mission_stone_modal: Optional[bool] = None,
+        has_shown_conversation_stone_modal: Optional[bool] = None,
         avatar_c_unlocked: Optional[bool] = None,
     ) -> Optional[Dict[str, Any]]:
         """
@@ -360,6 +362,10 @@ class UserService:
                         data["has_shown_send_tooltip"] = has_shown_send_tooltip
                     if has_shown_progress_bar_tooltip is not None:
                         data["has_shown_progress_bar_tooltip"] = has_shown_progress_bar_tooltip
+                    if has_shown_mission_stone_modal is not None:
+                        data["has_shown_mission_stone_modal"] = has_shown_mission_stone_modal
+                    if has_shown_conversation_stone_modal is not None:
+                        data["has_shown_conversation_stone_modal"] = has_shown_conversation_stone_modal
                     if avatar_c_unlocked is not None:
                         data["avatar_c_unlocked"] = avatar_c_unlocked
 
@@ -379,6 +385,10 @@ class UserService:
                         state.has_shown_send_tooltip = has_shown_send_tooltip
                     if has_shown_progress_bar_tooltip is not None:
                         state.has_shown_progress_bar_tooltip = has_shown_progress_bar_tooltip
+                    if has_shown_mission_stone_modal is not None:
+                        state.has_shown_mission_stone_modal = has_shown_mission_stone_modal
+                    if has_shown_conversation_stone_modal is not None:
+                        state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
                     if avatar_c_unlocked is not None:
                         state.avatar_c_unlocked = avatar_c_unlocked
 

BIN
frontend/src/assets/dhnls-tag.png


BIN
frontend/src/assets/rwnls-tab.png


+ 23 - 0
frontend/src/components/MissionEnergyStoneModal.css

@@ -0,0 +1,23 @@
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@keyframes slideUp {
+  from {
+    transform: translateY(100%);
+  }
+  to {
+    transform: translateY(0);
+  }
+}
+
+.mission-energy-stone-modal {
+  /* 确保弹窗在最上层 */
+  will-change: transform;
+}
+

+ 155 - 0
frontend/src/components/MissionEnergyStoneModal.tsx

@@ -0,0 +1,155 @@
+import React, { useEffect } from 'react';
+import { px2vmin, px2vw } from '@/utils/viewport';
+import './MissionEnergyStoneModal.css';
+import rwnlsTabImage from '@/assets/rwnls-tab.png';
+import dhnlsTagImage from '@/assets/dhnls-tag.png';
+
+interface MissionEnergyStoneModalProps {
+  visible: boolean;
+  onClose: () => void;
+  stoneType?: 'mission' | 'conversation';
+  children?: React.ReactNode;
+}
+
+export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = ({
+  visible,
+  onClose,
+  stoneType = 'mission',
+  children,
+}) => {
+  const imageSrc = stoneType === 'mission' ? rwnlsTabImage : dhnlsTagImage;
+  const title = stoneType === 'mission' ? '任务能量石' : '对话能量石';
+  const description = stoneType === 'mission'
+    ? '你可以通过完成各种任务获得该能量石,用于拯救生命树。注意,可能有隐藏任务哦!'
+    : '你可以通过和Lumi的对话获得该能量石,用于拯救生命树。累计完成30s语音输入,可以获得一块对话能量石。';
+  useEffect(() => {
+    if (visible) {
+      // 防止背景滚动
+      document.body.style.overflow = 'hidden';
+    } else {
+      document.body.style.overflow = '';
+    }
+
+    return () => {
+      document.body.style.overflow = '';
+    };
+  }, [visible]);
+
+  if (!visible) {
+    return null;
+  }
+
+  return (
+    <>
+      {/* 遮罩层 */}
+      <div
+        className="mission-energy-stone-modal-mask"
+        onClick={onClose}
+        style={{
+          position: 'fixed',
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          backgroundColor: 'rgba(0, 0, 0, 0.5)',
+          zIndex: 9998,
+          animation: 'fadeIn 0.3s ease-out',
+        }}
+      />
+
+      {/* 弹窗内容 */}
+      <div
+        className="mission-energy-stone-modal"
+        style={{
+          position: 'fixed',
+          bottom: 0,
+          left: '50%',
+          transform: 'translateX(-50%)',
+          width: px2vw(375),
+          height: px2vmin(192),
+          backgroundColor: '#34313D',
+          borderTopLeftRadius: px2vmin(32),
+          borderTopRightRadius: px2vmin(32),
+          zIndex: 9999,
+          // animation: 'slideUp 0.3s ease-out',
+          display: 'flex',
+          flexDirection: 'column',
+          alignItems: 'center',
+          // justifyContent: 'flex-start',
+          paddingTop: px2vmin(12),
+        }}
+      >
+        {/* 顶部元素 */}
+        <div
+          style={{
+            width: px2vmin(32),
+            height: px2vmin(4),
+            backgroundColor: '#79747E',
+            borderRadius: px2vmin(4),
+          }}
+        />
+
+        {/* 主要内容区域 */}
+        <div
+          style={{
+            marginTop: px2vmin(54 - 12 - 4), // 距离弹窗顶部54px,减去paddingTop 12px和顶部元素高度4px
+            width: px2vmin(311),
+            height: px2vmin(100),
+            display: 'flex',
+            flexDirection: 'row',
+            alignItems: 'center',
+            gap: px2vmin(16),
+          }}
+        >
+          {/* 左边图片 */}
+          <img
+            src={imageSrc}
+            alt={title}
+            style={{
+              width: px2vmin(80),
+              height: px2vmin(80),
+              flexShrink: 0,
+            }}
+          />
+
+          {/* 右边文字区域 */}
+          <div
+            style={{
+              flex: 1,
+              display: 'flex',
+              flexDirection: 'column',
+              justifyContent: 'center',
+              gap: px2vmin(8),
+            }}
+          >
+            {/* 上半部分:标题 */}
+            <div
+              style={{
+                color: '#FFFFFF',
+                fontSize: px2vmin(20),
+                fontWeight: 'bold',
+                lineHeight: 1.2,
+              }}
+            >
+              {title}
+            </div>
+
+            {/* 下半部分:描述 */}
+            <div
+              style={{
+                color: '#FFFFFF',
+                fontSize: px2vmin(14),
+                lineHeight: 1.4,
+              }}
+            >
+              {description}
+            </div>
+          </div>
+        </div>
+
+        {children}
+      </div>
+    </>
+  );
+};
+

+ 9 - 8
frontend/src/components/UserGuard.tsx

@@ -12,20 +12,21 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
   const location = useLocation();
 
   useEffect(() => {
-    // 首页路由由 RootRedirect 处理,不在此处检查
+    // 根路由交给 RootRedirect 处理
     if (location.pathname === '/') {
       return;
     }
 
-    // 检查用户ID是否存在
     if (!hasUserId()) {
-      // 清空用户相关的路由和页面状态
-      clearUserRouteData();
-      
-      // 跳转到首页(首页会创建用户)
-      navigate('/', { replace: true });
+      // 当本地没有 user_id 时,仅在需要重定向时才清理并跳转
+      if (location.pathname !== '/welcome' && location.pathname !== '/naming') {
+        // 清理本地路由数据,然后跳转到 /welcome
+        clearUserRouteData();
+        navigate('/welcome', { replace: true });
+      }
+      // 在 /welcome 和 /naming 上保留本地路由数据,避免刚写入就被清空
     }
-  }, [navigate, location.pathname]);
+  }, [location.pathname, navigate]);
 
   return <>{children}</>;
 };

+ 346 - 56
frontend/src/pages/ChatBotPage.tsx

@@ -16,8 +16,6 @@ import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import avatarC from '@/assets/Avatar-C.svg';
 import treesImage from '@/assets/Group-3465183.svg';
-import animaZoneAvatar from '@/assets/Avatar-anima-zone.png';
-import animaZoneAvatarGif from '@/assets/02.GIF';
 import audioButton from '@/assets/audio-button.png';
 import avatarHint from '@/assets/Avatar-hint.png';
 import { AudioWave, type AudioWaveHandle } from '@/components/AudioWave';
@@ -33,6 +31,7 @@ import tooltipsProgressBar from '@/assets/tooltips-progress-bar.svg';
 import missionEnergyStone from '@/assets/Mission-Energy-Stone.png';
 import conversationEnergyStone from '@/assets/Conversation-Energy-Stone.png';
 import { useEnergyStones, type EnergyStoneEventPayload } from '@/hooks/useEnergyStones';
+import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
 
 const MAX_RECORDING_DURATION_MS = 60_000;
 const AVATAR_C_UNLOCK_THRESHOLD_MS = 60_000;
@@ -40,6 +39,29 @@ const COUNTDOWN_THRESHOLD_MS = 10_000;
 
 const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
 
+// GIF URL 常量
+const DEFAULT_AVATAR_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/01-Default.gif';
+const POSITIVE_EMOTION_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/04-Positive%20emotion.gif';
+const NEGATIVE_EMOTION_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/05-Negative%20emotion.gif';
+const TALKING_GIF = 'https://tolan.oss-cn-hangzhou.aliyuncs.com/06-talking.gif';
+
+// 情绪分类函数
+const getEmotionCategory = (emotion: string): 'positive' | 'negative' | 'neutral' => {
+  // 正向情绪
+  const positiveEmotions = ['happy', 'excited', 'encouraging', 'satisfied', 'friendly'];
+  // 反向情绪
+  const negativeEmotions = ['sad', 'angry', 'worried', 'confused', 'sorry'];
+  
+  if (positiveEmotions.includes(emotion)) {
+    return 'positive';
+  }
+  if (negativeEmotions.includes(emotion)) {
+    return 'negative';
+  }
+  // neutral, surprised, thoughtful 等归为中性
+  return 'neutral';
+};
+
 export const ChatBotPage = () => {
   const navigate = useNavigate();
   const {
@@ -65,6 +87,8 @@ export const ChatBotPage = () => {
   const wsRef = useRef<WebSocketManager | null>(null);
   const chunkIndexRef = useRef(0);
   const connectionLostRef = useRef(false);
+  const activeRequestIdRef = useRef<string | null>(null);
+  const cancelledRequestIdsRef = useRef<Set<string>>(new Set());
   const mobileAudioPreparedRef = useRef(false);
   const sendTimeRef = useRef<number>(0);
   const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
@@ -91,9 +115,22 @@ export const ChatBotPage = () => {
     return false;
   });
   const sendingTimeoutRef = useRef<number | null>(null);
+  const updateRequestId = useCallback(
+    (id: string | null) => {
+      activeRequestIdRef.current = id;
+      setRequestId(id);
+    },
+    [setRequestId]
+  );
+
+  useEffect(() => {
+    activeRequestIdRef.current = currentRequestId;
+  }, [currentRequestId]);
   const [showCancelTooltip, setShowCancelTooltip] = useState(false);
   const [showSendTooltip, setShowSendTooltip] = useState(false);
   const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
+  const [showMissionStoneModal, setShowMissionStoneModal] = useState(false);
+  const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
   const [hasShownProgressBarTooltip, setHasShownProgressBarTooltip] = useState(() => {
     const stored = getStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP);
     return stored === 'true';
@@ -143,7 +180,10 @@ export const ChatBotPage = () => {
     const initialLayout = calculateLayout();
     return initialLayout;
   });
-  const [isAvatarGifReady, setIsAvatarGifReady] = useState(false);
+  // 当前显示的头像 GIF URL
+  const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(DEFAULT_AVATAR_GIF);
+  // GIF 切换定时器
+  const emotionGifTimerRef = useRef<number | null>(null);
   
   const timingRef = useRef({
     recordStart: 0,
@@ -184,38 +224,6 @@ 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) {
@@ -583,6 +591,32 @@ export const ChatBotPage = () => {
             setIsAvatarCUnlocked(true);
             setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
           }
+          if (state.has_shown_mission_stone_modal) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
+          }
+          if (state.has_shown_conversation_stone_modal) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL, 'true');
+          }
+
+          // 如果本地记录为 true 但后端为 false,尝试补偿同步
+          const localMissionShown = getStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL) === 'true';
+          if (localMissionShown && !state.has_shown_mission_stone_modal) {
+            updateUserUIState(userId, {
+              has_shown_mission_stone_modal: true,
+            }).catch(() => {
+              // 静默处理,后续会继续尝试
+            });
+          }
+
+          const localConversationShown =
+            getStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL) === 'true';
+          if (localConversationShown && !state.has_shown_conversation_stone_modal) {
+            updateUserUIState(userId, {
+              has_shown_conversation_stone_modal: true,
+            }).catch(() => {
+              // 静默处理
+            });
+          }
         })
         .catch(() => {
           // 拉取失败不影响主流程
@@ -641,6 +675,13 @@ export const ChatBotPage = () => {
             performanceLogger.recordPlaybackEnd(requestId);
           }
           // 进度条已基于用户说话时长更新,无需在播放完成后再次更新
+          // 清理情绪 GIF 切换定时器
+          if (emotionGifTimerRef.current) {
+            clearTimeout(emotionGifTimerRef.current);
+            emotionGifTimerRef.current = null;
+          }
+          // 恢复默认头像 GIF
+          setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
           setState('idle');
           reset();
         });
@@ -665,6 +706,13 @@ export const ChatBotPage = () => {
               recorderRef.current?.stop();
               resetFrequencyVisualization();
               playerRef.current?.stop();
+              // 清理情绪 GIF 切换定时器
+              if (emotionGifTimerRef.current) {
+                clearTimeout(emotionGifTimerRef.current);
+                emotionGifTimerRef.current = null;
+              }
+              // 恢复默认头像 GIF
+              setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
               reset();
               setError(null);
             }
@@ -686,6 +734,14 @@ export const ChatBotPage = () => {
           resetFrequencyVisualization();
           playerRef.current?.stop();
           
+          // 清理情绪 GIF 切换定时器
+          if (emotionGifTimerRef.current) {
+            clearTimeout(emotionGifTimerRef.current);
+            emotionGifTimerRef.current = null;
+          }
+          // 恢复默认头像 GIF
+          setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
+          
           antMessage.error('语音服务连接断开,请稍后重试');
           setError('语音服务连接断开,请稍后重试');
         });
@@ -713,6 +769,9 @@ export const ChatBotPage = () => {
       if (progressBarTooltipTimerRef.current) {
         clearTimeout(progressBarTooltipTimerRef.current);
       }
+      if (emotionGifTimerRef.current) {
+        clearTimeout(emotionGifTimerRef.current);
+      }
     };
   }, [resetFrequencyVisualization]);
 
@@ -723,9 +782,26 @@ export const ChatBotPage = () => {
       console.warn('[ChatBotPage] 收到缺少 requestId 的消息,已忽略', message.type);
       return;
     }
+    const activeRequestId = activeRequestIdRef.current;
+    const isCancelledRequest = cancelledRequestIdsRef.current.has(requestId);
+
+    if (isCancelledRequest) {
+      if (message.type === 'audio_response' && message.isLast) {
+        cancelledRequestIdsRef.current.delete(requestId);
+      }
+      console.log(`[${message.type}] [${requestId.substring(0, 8)}] 已取消的请求,忽略响应`);
+      return;
+    }
     
     switch (message.type) {
       case 'text_result':
+        // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
+        // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
+        if (activeRequestId !== null && requestId !== activeRequestId) {
+          console.log(`[文本结果] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的文本结果`);
+          break;
+        }
+        
         if (message.isFinal) {
           performanceLogger.recordFinalText(requestId, message.text, message.timestamp);
         } else if (!performanceLogger.hasFirstText(requestId)) {
@@ -735,11 +811,62 @@ export const ChatBotPage = () => {
         break;
 
       case 'emotion':
+        // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
+        // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
+        if (activeRequestId !== null && requestId !== activeRequestId) {
+          console.log(`[情绪] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的情绪`);
+          break;
+        }
+        
         performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
         setEmotion(message.emotion);
+        
+        // 获取最新状态,确保能正确判断是否正在播放
+        const appState = useAppStore.getState();
+        // 如果正在播放或发送,根据新收到的情绪更新 GIF
+        if (appState.currentState === 'playing' || appState.currentState === 'sending') {
+          const emotionCategory = getEmotionCategory(message.emotion);
+          if (emotionCategory === 'positive') {
+            // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
+            setCurrentAvatarGif(POSITIVE_EMOTION_GIF);
+            if (emotionGifTimerRef.current) {
+              clearTimeout(emotionGifTimerRef.current);
+            }
+            emotionGifTimerRef.current = window.setTimeout(() => {
+              setCurrentAvatarGif(TALKING_GIF);
+              emotionGifTimerRef.current = null;
+            }, 2000);
+          } else if (emotionCategory === 'negative') {
+            // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
+            setCurrentAvatarGif(NEGATIVE_EMOTION_GIF);
+            if (emotionGifTimerRef.current) {
+              clearTimeout(emotionGifTimerRef.current);
+            }
+            emotionGifTimerRef.current = window.setTimeout(() => {
+              setCurrentAvatarGif(TALKING_GIF);
+              emotionGifTimerRef.current = null;
+            }, 5000);
+          } else {
+            // 中性情绪:直接显示 talking GIF
+            setCurrentAvatarGif(TALKING_GIF);
+          }
+        }
         break;
 
       case 'audio_response':
+        // 如果当前状态是 recording,说明用户已经点击了录音按钮,忽略后续的音频响应
+        if (currentState === 'recording') {
+          console.log(`[音频响应] [${requestId.substring(0, 8)}] 用户已开始录音,忽略音频响应`);
+          break;
+        }
+        
+        // 如果 currentRequestId 不为 null,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
+        // 如果 activeRequestId 为 null,说明是新请求的开始,允许处理
+        if (activeRequestId !== null && requestId !== activeRequestId) {
+          console.log(`[音频响应] [${requestId.substring(0, 8)}] 请求ID不匹配,丢弃旧请求的音频响应`);
+          break;
+        }
+        
         // 清除发送超时
         if (sendingTimeoutRef.current) {
           clearTimeout(sendingTimeoutRef.current);
@@ -750,6 +877,35 @@ export const ChatBotPage = () => {
           performanceLogger.recordFirstAudio(requestId, message.timestamp);
           performanceLogger.recordPlaybackStart(requestId);
           setState('playing');
+          
+          // 获取最新的情绪状态(可能情绪消息已经到达)
+          const latestEmotion = useAppStore.getState().currentEmotion;
+          // 根据情绪类型设置 GIF
+          const emotionCategory = getEmotionCategory(latestEmotion);
+          if (emotionCategory === 'positive') {
+            // 正向情绪:先显示正向情绪 GIF,2秒后切换到 talking GIF
+            setCurrentAvatarGif(POSITIVE_EMOTION_GIF);
+            if (emotionGifTimerRef.current) {
+              clearTimeout(emotionGifTimerRef.current);
+            }
+            emotionGifTimerRef.current = window.setTimeout(() => {
+              setCurrentAvatarGif(TALKING_GIF);
+              emotionGifTimerRef.current = null;
+            }, 2000);
+          } else if (emotionCategory === 'negative') {
+            // 反向情绪:先显示反向情绪 GIF,5秒后切换到 talking GIF
+            setCurrentAvatarGif(NEGATIVE_EMOTION_GIF);
+            if (emotionGifTimerRef.current) {
+              clearTimeout(emotionGifTimerRef.current);
+            }
+            emotionGifTimerRef.current = window.setTimeout(() => {
+              setCurrentAvatarGif(TALKING_GIF);
+              emotionGifTimerRef.current = null;
+            }, 5000);
+          } else {
+            // 中性情绪或无情绪:直接显示 talking GIF
+            setCurrentAvatarGif(TALKING_GIF);
+          }
         }
         
         if (message.data) {
@@ -778,6 +934,13 @@ export const ChatBotPage = () => {
         console.error(`[错误] [${requestId?.substring(0, 8) || ''}]`, message.message, message.code);
         antMessage.error(message.message);
         setError(message.message);
+        // 清理情绪 GIF 切换定时器
+        if (emotionGifTimerRef.current) {
+          clearTimeout(emotionGifTimerRef.current);
+          emotionGifTimerRef.current = null;
+        }
+        // 恢复默认头像 GIF
+        setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
         performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
         break;
 
@@ -807,9 +970,40 @@ export const ChatBotPage = () => {
       return;
     }
     
-    // 如果正在播放,先停止播放
-    if (currentState === 'playing') {
+    // 保存旧请求ID(用于取消和过滤旧消息)
+    const oldRequestId = currentRequestId;
+    const wasPlaying = currentState === 'playing' || currentState === 'sending';
+    
+    // 如果正在播放,先停止播放并重置状态
+    if (wasPlaying) {
+      // 停止音频播放
       playerRef.current?.stop();
+      
+      // 清理情绪 GIF 切换定时器
+      if (emotionGifTimerRef.current) {
+        clearTimeout(emotionGifTimerRef.current);
+        emotionGifTimerRef.current = null;
+      }
+      
+      // 立即恢复默认头像 GIF
+      setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
+      
+      // 取消当前的请求(如果有),丢弃上一次请求的返回
+      if (oldRequestId) {
+        wsRef.current?.sendCancel(oldRequestId);
+        cancelledRequestIdsRef.current.add(oldRequestId);
+        performanceLogger.clearRequest(oldRequestId, '用户中断播放开始新录音');
+      }
+      
+      // 清理旧请求的状态(丢弃上一次请求的返回)
+      setRecognizedText('');
+      setEmotion('neutral');
+      
+      // 清理发送超时
+      if (sendingTimeoutRef.current) {
+        clearTimeout(sendingTimeoutRef.current);
+        sendingTimeoutRef.current = null;
+      }
     }
     
     // 其他状态(idle/error/sending)都允许开始录音
@@ -822,27 +1016,31 @@ export const ChatBotPage = () => {
     // 点击录音按钮时,隐藏进度条提示
     dismissProgressBarTooltip();
 
-    if (isMobileDevice() && !mobileAudioPreparedRef.current) {
-      try {
-        await prepareMobileAudio();
-        requestVolumeControl();
-        mobileAudioPreparedRef.current = true;
-        antMessage.info('请确保设备音量已调高');
-      } catch (error) {
-        // 准备移动端音频失败,静默处理
-      }
-    }
-
     // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
     playerRef.current?.stop();
 
+    // 生成新的请求ID并立即设置,确保后续消息能正确匹配
     const requestId = uuidv4();
     performanceLogger.startRequest(requestId);
-    setRequestId(requestId);
+    updateRequestId(requestId);
     setState('recording');
     chunkIndexRef.current = 0;
     setRecognizedText('');
     
+    // 准备移动端音频(在设置状态之后,避免阻塞状态更新)
+    if (isMobileDevice() && !mobileAudioPreparedRef.current) {
+      // 异步准备,不阻塞录音开始
+      prepareMobileAudio()
+        .then(() => {
+          requestVolumeControl();
+          mobileAudioPreparedRef.current = true;
+          antMessage.info('请确保设备音量已调高');
+        })
+        .catch(() => {
+          // 准备移动端音频失败,静默处理
+        });
+    }
+    
     timingRef.current = {
       recordStart: Date.now(),
       recordEnd: 0,
@@ -850,8 +1048,15 @@ export const ChatBotPage = () => {
       emotionReceived: 0,
     };
 
+    // 立即启动录音,确保第一次点击就能开始
     try {
       await recorderRef.current?.start((pcmData) => {
+        // 检查当前状态,如果已经不是recording,说明用户可能又点击了,忽略
+        const currentStateCheck = useAppStore.getState().currentState;
+        if (currentStateCheck !== 'recording') {
+          return;
+        }
+        
         if (chunkIndexRef.current === 0) {
           performanceLogger.recordFirstChunkSent(requestId);
         }
@@ -860,16 +1065,25 @@ export const ChatBotPage = () => {
         wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
         chunkIndexRef.current++;
       }, pushFrequencyFrame);
+      
+      // 录音启动成功,开始计时
+      startRecordingTimer();
     } catch (error) {
       console.error(`[录音] [${requestId.substring(0, 8)}] 启动失败`, error);
       antMessage.error('麦克风启动失败,请检查权限或重试');
       performanceLogger.clearRequest(requestId, 'recorder_start_failed');
       resetFrequencyVisualization();
+      // 清理情绪 GIF 切换定时器
+      if (emotionGifTimerRef.current) {
+        clearTimeout(emotionGifTimerRef.current);
+        emotionGifTimerRef.current = null;
+      }
+      // 恢复默认头像 GIF
+      setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
       setState('idle');
       reset();
       return;
     }
-    startRecordingTimer();
   };
 
   // 发送录音
@@ -884,6 +1098,13 @@ export const ChatBotPage = () => {
       antMessage.error('语音服务未连接,请重新录音发送');
       recorderRef.current?.stop();
       resetFrequencyVisualization();
+      // 清理情绪 GIF 切换定时器
+      if (emotionGifTimerRef.current) {
+        clearTimeout(emotionGifTimerRef.current);
+        emotionGifTimerRef.current = null;
+      }
+      // 恢复默认头像 GIF
+      setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
       performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
       reset();
       return;
@@ -893,6 +1114,13 @@ export const ChatBotPage = () => {
       antMessage.error('语音服务尚未准备完成,请重新录音发送');
       recorderRef.current?.stop();
       resetFrequencyVisualization();
+      // 清理情绪 GIF 切换定时器
+      if (emotionGifTimerRef.current) {
+        clearTimeout(emotionGifTimerRef.current);
+        emotionGifTimerRef.current = null;
+      }
+      // 恢复默认头像 GIF
+      setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
       performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
       reset();
       return;
@@ -928,6 +1156,13 @@ export const ChatBotPage = () => {
         const shortId = currentRequestId ? currentRequestId.substring(0, 8) : '';
         console.warn(`[超时] [${shortId}] 响应超时,自动重置`);
         antMessage.warning('响应超时,请重试');
+        // 清理情绪 GIF 切换定时器
+        if (emotionGifTimerRef.current) {
+          clearTimeout(emotionGifTimerRef.current);
+          emotionGifTimerRef.current = null;
+        }
+        // 恢复默认头像 GIF
+        setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
         setState('idle');
         reset();
         performanceLogger.clearRequest(currentRequestId, '响应超时');
@@ -947,17 +1182,23 @@ export const ChatBotPage = () => {
     resetFrequencyVisualization();
     stopRecordingTimer();
 
+    // 清理情绪 GIF 切换定时器
+    if (emotionGifTimerRef.current) {
+      clearTimeout(emotionGifTimerRef.current);
+      emotionGifTimerRef.current = null;
+    }
+    // 恢复默认头像 GIF
+    setCurrentAvatarGif(DEFAULT_AVATAR_GIF);
+
     wsRef.current?.sendCancel(currentRequestId);
+    cancelledRequestIdsRef.current.add(currentRequestId);
     performanceLogger.clearRequest(currentRequestId);
     reset();
   };
 
   const avatarHintSize = Math.round(layout.avatarCSize * 1.8);
 
-  const isAvatarAnimating = currentState === 'playing';
-  const currentAvatarSrc =
-    isAvatarAnimating && isAvatarGifReady ? animaZoneAvatarGif : animaZoneAvatar;
-  const currentAvatarAlt = isAvatarAnimating ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
+  const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
   const energyStoneAssets: Record<'mission' | 'conversation', string> = {
     mission: missionEnergyStone,
     conversation: conversationEnergyStone,
@@ -1088,7 +1329,49 @@ export const ChatBotPage = () => {
                 className="energy-stone"
                 data-type={stone.stoneType}
                 data-fading={stone.fading ? 'true' : 'false'}
-                onClick={() => handleStoneClick(stone.stoneId)}
+                onClick={async () => {
+                  handleStoneClick(stone.stoneId);
+                  
+                  // 检查是否是第一次点击,决定是否显示弹窗
+                  const storageKey = stone.stoneType === 'mission' 
+                    ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL 
+                    : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
+                  
+                  const hasShown = getStorage(storageKey);
+                  
+                  if (hasShown !== 'true') {
+                    // 第一次点击,显示弹窗
+                    setEnergyStoneModalType(stone.stoneType);
+                    setShowMissionStoneModal(true);
+                    
+                    // 同步到数据库(成功后再更新 localStorage)
+                    const userId = getUserId();
+                    let synced = false;
+                    if (userId) {
+                      try {
+                        if (stone.stoneType === 'mission') {
+                          await updateUserUIState(userId, {
+                            has_shown_mission_stone_modal: true,
+                          });
+                        } else {
+                          await updateUserUIState(userId, {
+                            has_shown_conversation_stone_modal: true,
+                          });
+                        }
+                        synced = true;
+                      } catch (error) {
+                        console.error('同步能量石弹窗状态到数据库失败:', error);
+                      }
+                    } else {
+                      // 没有 userId(游客态),仅更新本地,等获取 userId 后再补偿
+                      synced = true;
+                    }
+                    
+                    if (synced) {
+                      setStorage(storageKey, 'true');
+                    }
+                  }
+                }}
                 style={{
                     width: px2vmin(baseSize),
                     height: px2vmin(baseSize),
@@ -1121,7 +1404,7 @@ export const ChatBotPage = () => {
         }}
       >
         <img
-          src={currentAvatarSrc}
+          src={currentAvatarGif}
           alt={currentAvatarAlt}
           loading="eager"
           decoding="sync"
@@ -1562,6 +1845,13 @@ export const ChatBotPage = () => {
           正在重连语音服务...
         </div>
       )}
+
+      {/* 能量石弹窗 */}
+      <MissionEnergyStoneModal
+        visible={showMissionStoneModal}
+        onClose={() => setShowMissionStoneModal(false)}
+        stoneType={energyStoneModalType}
+      />
     </div>
   );
 };

+ 120 - 0
frontend/src/pages/FirstLoginPage.tsx

@@ -0,0 +1,120 @@
+import { useEffect } from 'react';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
+import { updateUserUIState } from '@/utils/api';
+import { px2vmin } from '@/utils/viewport';
+import giftProgressBar from '@/assets/gift-progress-bar.png';
+import wechatLogin from '@/assets/wechat-login.png';
+import qqLogin from '@/assets/qq-login.png';
+import phoneLogin from '@/assets/phone-login.png';
+import emailLogin from '@/assets/email-login.png';
+
+export const FirstLoginPage = () => {
+  useEffect(() => {
+    // 记录用户当前所在页面
+    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'first-login',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+  }, []);
+
+  return (
+    <div>
+      {/* 顶部进度条图片 */}
+      <img
+        src={giftProgressBar}
+        alt="gift progress bar"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(64),
+          width: px2vmin(195),
+          transform: 'translateX(-50%)',
+        }}
+      />
+      {/* 中部 GIF 动画图片 */}
+      <img
+        src="https://tolan.oss-cn-hangzhou.aliyuncs.com/03-Cheer.gif"
+        alt="cheer animation"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(192),
+          width: px2vmin(343),
+          transform: 'translateX(-50%)',
+        }}
+      />
+      {/* 微信登录按钮 */}
+      <img
+        src={wechatLogin}
+        alt="wechat login"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(567),
+          width: px2vmin(279),
+          transform: 'translateX(-50%)',
+        }}
+      />
+      {/* 其他登录方式文字 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(687),
+          transform: 'translateX(-50%)',
+          color: '#F5F3F0',
+          fontSize: px2vmin(12),
+          whiteSpace: 'nowrap',
+        }}
+      >
+        —— 其他登陆方式 ——
+      </div>
+      {/* 按钮行:QQ / 手机 / 邮箱 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(715),
+          transform: 'translateX(-50%)',
+          display: 'flex',
+          gap: px2vmin(32),
+          alignItems: 'center',
+          justifyContent: 'center',
+          width: 'fit-content',
+        }}
+      >
+        <img
+          src={qqLogin}
+          alt="qq login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+        <img
+          src={phoneLogin}
+          alt="phone login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+        <img
+          src={emailLogin}
+          alt="email login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+      </div>
+    </div>
+  );
+};
+
+

+ 32 - 8
frontend/src/pages/GreetingPage.tsx

@@ -1,9 +1,9 @@
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '../utils/storage';
-import { getUserInfo, updateUsername, updateUserUIState } from '../utils/api';
+import { message as antMessage } from 'antd';
+import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes, setUserId, setUserName } from '../utils/storage';
+import { getUserInfo, updateUsername, updateUserUIState, createUser } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
-import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 
 const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());
@@ -21,14 +21,38 @@ export const GreetingPage = () => {
   // 组件加载时从 localStorage 读取姓名,并记录当前页面
   useEffect(() => {
     const loadUserData = async () => {
+      console.log('[GreetingPage] loadUserData start');
       // 记录用户当前所在页面
-      setStorage(STORAGE_KEYS.CURRENT_PAGE, 'naming');
+      const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'naming');
+      console.log('[GreetingPage] set CURRENT_PAGE=naming result:', ok);
       
       // 记录路由访问
       markRouteVisited('/naming');
-
       const visited = getVisitedRoutes();
-      const userId = getUserId();
+      console.log('[GreetingPage] visited routes after markRouteVisited:', visited);
+
+      // 确保存在 userId:如果没有,则在进入 /naming 时创建用户
+      let userId = getUserId();
+      if (!userId) {
+        try {
+          const response = await createUser();
+          if (response.success && response.data?.user_id) {
+            userId = response.data.user_id;
+            setUserId(userId);
+            if (response.data.username) {
+              setUserName(response.data.username);
+            }
+          } else {
+            throw new Error('创建用户失败:响应数据无效');
+          }
+        } catch (error) {
+          console.error('[GreetingPage] 创建用户失败:', error);
+          antMessage.warning('响应超时,请重试');
+          // 创建用户失败时,不阻塞页面,后续依赖 userId 的逻辑将被跳过
+          userId = null;
+        }
+      }
+
       if (userId) {
         updateUserUIState(userId, {
           current_page: 'naming',
@@ -48,7 +72,7 @@ export const GreetingPage = () => {
         setHasUserEdited(false);
       } else {
         // 如果不存在,从后端获取用户信息
-        const user_id = getUserId();
+        const user_id = userId || getUserId();
         if (user_id) {
           try {
             const response = await getUserInfo(user_id);
@@ -330,7 +354,7 @@ export const GreetingPage = () => {
           }}
         >
           <img
-            src={AvatarZone}
+            src="https://tolan.oss-cn-hangzhou.aliyuncs.com/01-Default.gif"
             alt="Character Avatar"
             className="w-full h-full object-contain object-bottom"
             style={{ width: '100%', height: '100%' }}

+ 41 - 80
frontend/src/pages/Level5WelcomePage.tsx

@@ -1,14 +1,12 @@
 import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import giftProgressBar from '@/assets/gift-progress-bar.png';
-import wechatLogin from '@/assets/wechat-login.png';
-import qqLogin from '@/assets/qq-login.png';
-import phoneLogin from '@/assets/phone-login.png';
-import emailLogin from '@/assets/email-login.png';
 
 export const Level5WelcomePage = () => {
+  const navigate = useNavigate();
 
   useEffect(() => {
     // 记录用户当前所在页面
@@ -26,6 +24,17 @@ export const Level5WelcomePage = () => {
     }
   }, []);
 
+  useEffect(() => {
+    // 文案动画与过渡结束后,跳转到第一次登录界面
+    const timer = window.setTimeout(() => {
+      navigate('/first-login');
+    }, 8400);
+
+    return () => {
+      window.clearTimeout(timer);
+    };
+  }, [navigate]);
+
   return (
     <div>
       {/* 进度条图片 - Y: 64, 左右居中, width: 195, height: 32,动画结束后显示 */}
@@ -40,7 +49,7 @@ export const Level5WelcomePage = () => {
           // height: px2vmin(32),
           transform: 'translateX(-50%)',
           opacity: 0,
-          animation: 'imageSlideDownAndShake 1s ease-out 1.5s forwards',
+          animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
         }}
       />
       {/* GIF动画图片 - Y: 192, 左右居中, width: 343, height: 280,动画结束后显示 */}
@@ -55,7 +64,7 @@ export const Level5WelcomePage = () => {
           // height: px2vmin(280),
           transform: 'translateX(-50%)',
           opacity: 0,
-          animation: 'imageSlideDownAndShake 1s ease-out 1.5s forwards',
+          animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
         }}
       />
       {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示,5.4秒后消失 */}
@@ -70,7 +79,7 @@ export const Level5WelcomePage = () => {
           fontWeight: 'bold',
           whiteSpace: 'nowrap',
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 1.5s forwards',
+          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
         }}
       >
         Congratulation!
@@ -87,79 +96,11 @@ export const Level5WelcomePage = () => {
           textAlign: 'center',
           width: px2vmin(270),
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 1.5s forwards',
+          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
         }}
       >
         恭喜你成功升到5级!聊了这么长时间,不如登陆一下让我们保持联系吧。登陆后有惊喜礼物等着你哦
       </div>
-      {/* 微信登录按钮 - Y: 567, 左右居中, width: 279,文字消失后显示 */}
-      <img
-        src={wechatLogin}
-        alt="wechat login"
-        style={{
-          position: 'absolute',
-          left: '50%',
-          top: px2vmin(567),
-          width: px2vmin(279),
-          transform: 'translateX(-50%)',
-          opacity: 0,
-          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
-        }}
-      />
-      {/* 其他登录方式文字 - Y: 687, 左右居中, 12px, 颜色F5F3F0,文字消失后显示 */}
-      <div
-        style={{
-          position: 'absolute',
-          left: '50%',
-          top: px2vmin(687),
-          transform: 'translateX(-50%)',
-          color: '#F5F3F0',
-          fontSize: px2vmin(12),
-          whiteSpace: 'nowrap',
-          opacity: 0,
-          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
-        }}
-      >
-        —— 其他登陆方式 ——
-      </div>
-      {/* 按钮行 - Y: 715, 左右居中,三个按钮,间距32px */}
-      <div
-        style={{
-          position: 'absolute',
-          left: '50%',
-          top: px2vmin(715),
-          transform: 'translateX(-50%)',
-          display: 'flex',
-          gap: px2vmin(32),
-          alignItems: 'center',
-          justifyContent: 'center',
-          width: 'fit-content',
-          opacity: 0,
-          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
-        }}
-      >
-        <img
-          src={qqLogin}
-          alt="qq login"
-          style={{
-            height: px2vmin(48),
-          }}
-        />
-        <img
-          src={phoneLogin}
-          alt="phone login"
-          style={{
-            height: px2vmin(48),
-          }}
-        />
-        <img
-          src={emailLogin}
-          alt="email login"
-          style={{
-            height: px2vmin(48),
-          }}
-        />
-      </div>
       {/* 扩展圆形动画 - 设计稿:圆心(323, 147),最大半径236 */}
       <div
         style={{
@@ -223,9 +164,14 @@ export const Level5WelcomePage = () => {
             height: 0;
             opacity: 1;
           }
-          100% {
+          99% {
             width: ${px2vmin(236 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
             height: ${px2vmin(236 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(236 * 2)};
+            height: ${px2vmin(236 * 2)};
             opacity: 0;
           }
         }
@@ -235,9 +181,14 @@ export const Level5WelcomePage = () => {
             height: 0;
             opacity: 1;
           }
-          100% {
+          99% {
             width: ${px2vmin(332.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
             height: ${px2vmin(332.5 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(332.5 * 2)};
+            height: ${px2vmin(332.5 * 2)};
             opacity: 0;
           }
         }
@@ -247,9 +198,14 @@ export const Level5WelcomePage = () => {
             height: 0;
             opacity: 1;
           }
-          100% {
+          99% {
             width: ${px2vmin(340 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
             height: ${px2vmin(340 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(340 * 2)};
+            height: ${px2vmin(340 * 2)};
             opacity: 0;
           }
         }
@@ -259,9 +215,14 @@ export const Level5WelcomePage = () => {
             height: 0;
             opacity: 1;
           }
-          100% {
+          99% {
             width: ${px2vmin(258.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
             height: ${px2vmin(258.5 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(258.5 * 2)};
+            height: ${px2vmin(258.5 * 2)};
             opacity: 0;
           }
         }

+ 1 - 2
frontend/src/pages/LumiNamingPage.tsx

@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
 import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '../utils/storage';
 import { updateBotName, updateUserUIState } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
-import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 
 const DEFAULT_LUMI_NAME = 'Lumi';
@@ -249,7 +248,7 @@ export const LumiNamingPage = () => {
           }}
         >
           <img
-            src={AvatarZone}
+            src="https://tolan.oss-cn-hangzhou.aliyuncs.com/01-Default.gif"
             alt="Character Avatar"
             className="w-full h-full object-contain object-bottom"
             style={{ width: '100%', height: '100%' }}

+ 22 - 55
frontend/src/pages/RootRedirect.tsx

@@ -1,17 +1,15 @@
 import { useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { getNextUnvisitedRoute, hasUserId, setUserId, setUserName } from '@/utils/storage';
-import { createUser } from '@/utils/api';
-import { handleApiError } from '@/utils/errorHandler';
+import { getNextUnvisitedRoute, hasUserId } from '@/utils/storage';
 
 /**
  * 根路由重定向组件
  * 根据访问历史自动跳转到合适的页面
- * 如果用户ID不存在,自动创建用户
+ * 不再在这里创建用户,只做路由分发
  */
 export const RootRedirect = () => {
   const navigate = useNavigate();
-  const hasRunRef = useRef(false); // 防止在 React 严格模式下重复执行
+  const hasRunRef = useRef(false); // 防止在 React.StrictMode 下重复执行
 
   useEffect(() => {
     // 在开发环境的 React.StrictMode 下,useEffect 会执行两次
@@ -21,57 +19,26 @@ export const RootRedirect = () => {
     }
     hasRunRef.current = true;
 
-    const handleRedirect = async () => {
-      try {
-        console.log('[RootRedirect] 开始处理重定向...');
-        
-        // 检查用户ID是否存在
-        if (!hasUserId()) {
-          console.log('[RootRedirect] 用户ID不存在,开始创建用户...');
-          
-          // 添加超时处理(10秒超时)
-          const createUserPromise = createUser();
-          const timeoutPromise = new Promise<never>((_, reject) => {
-            setTimeout(() => reject(new Error('创建用户超时(10秒)')), 10000);
-          });
-          
-          const response = await Promise.race([createUserPromise, timeoutPromise]);
-          
-          console.log('[RootRedirect] 创建用户响应:', response);
-          
-          if (response.success && response.data?.user_id) {
-            // 保存用户ID到 localStorage
-            setUserId(response.data.user_id);
-            console.log('[RootRedirect] 用户ID已保存:', response.data.user_id);
-            
-            // 保存用户名到 localStorage
-            if (response.data?.username) {
-              setUserName(response.data.username);
-              console.log('[RootRedirect] 用户名已保存:', response.data.username);
-            }
-          } else {
-            throw new Error('创建用户失败:响应数据无效');
-          }
-        } else {
-          console.log('[RootRedirect] 用户ID已存在,跳过创建');
-        }
+    const handleRedirect = () => {
+      console.log('[RootRedirect] 开始处理重定向(仅根据路由状态跳转,不创建用户)...');
 
-        // 获取下一个未访问的路由
-        const nextRoute = getNextUnvisitedRoute();
-        console.log('[RootRedirect] 下一个路由:', nextRoute);
-        
-        if (nextRoute) {
-          // 有未访问的路由,跳转到下一个
-          navigate(nextRoute, { replace: true });
-        } else {
-          // 所有路由都访问过了,跳转到聊天页(最后一个路由)
-          navigate('/chatbot', { replace: true });
-        }
-      } catch (err) {
-        console.error('[RootRedirect] 错误:', err);
-        // 跳转到错误页面,允许重试(重试路径为当前路径)
-        const error = err instanceof Error ? err : new Error('未知错误');
-        handleApiError(error, '/');
+      // 如果本地没有 userId,默认进入 welcome
+      if (!hasUserId()) {
+        console.log('[RootRedirect] 本地无 user_id,跳转到 /welcome');
+        navigate('/welcome', { replace: true });
+        return;
+      }
+
+      // 获取下一个未访问的路由
+      const nextRoute = getNextUnvisitedRoute();
+      console.log('[RootRedirect] 下一个路由:', nextRoute);
+      
+      if (nextRoute) {
+        // 有未访问的路由,跳转到下一个
+        navigate(nextRoute, { replace: true });
+      } else {
+        // 所有路由都访问过了,跳转到聊天页(最后一个路由)
+        navigate('/chatbot', { replace: true });
       }
     };
 

+ 14 - 0
frontend/src/pages/WelcomePage.css

@@ -424,6 +424,20 @@ video::-webkit-media-controls-overlay-play-button {
   z-index: 2;
 }
 
+.agreement-login-tip {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: calc(env(safe-area-inset-bottom, 0px) + 56PX); /* 在协议文字上方留出间距 */
+  text-align: center;
+  font-size: 12PX; /* 与 .agreement-text 一致 */
+  line-height: 1.5;
+  color: rgba(245, 243, 240, 1); /* 白色文字,与协议文字一致 */
+  text-decoration: underline;
+  user-select: none;
+  z-index: 2;
+}
+
 .agreement-content {
   display: flex;
   align-items: center;

+ 15 - 5
frontend/src/pages/WelcomePage.tsx

@@ -1,5 +1,6 @@
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
+import { message as antMessage } from 'antd';
 import videojs from 'video.js';
 import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
@@ -27,13 +28,17 @@ export const WelcomePage = () => {
   const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); // 检查定时器
   const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 兜底定时器
   const [showSkipButton, setShowSkipButton] = useState(false); // 是否展示“跳过动画”按钮(只供用户手动跳转)
+  const hasShownTimeoutTipRef = useRef(false); // 是否已经提示过“响应超时”
 
   useEffect(() => {
+    console.log('[WelcomePage] init effect start');
     // 记录用户当前所在页面
-    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'welcome');
+    const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'welcome');
+    console.log('[WelcomePage] set CURRENT_PAGE=welcome result:', ok);
     
     // 记录路由访问
     markRouteVisited('/welcome');
+    console.log('[WelcomePage] visited routes after markRouteVisited:', getVisitedRoutes());
 
     // 将页面与路由状态同步到后端(如果已有 user_id)
     const userId = getUserId();
@@ -42,8 +47,12 @@ export const WelcomePage = () => {
       updateUserUIState(userId, {
         current_page: 'welcome',
         visited_routes: visited,
-      }).catch(() => {
-        // 同步失败不影响前端流程,静默处理
+      }).catch((error) => {
+        console.warn('[WelcomePage] 同步页面状态失败:', error);
+        if (!hasShownTimeoutTipRef.current) {
+          hasShownTimeoutTipRef.current = true;
+          antMessage.warning('响应超时,请重试');
+        }
       });
     }
 
@@ -590,10 +599,11 @@ export const WelcomePage = () => {
             Hello,
           </div>
           
-          {/* 协议文字 */}
+          {/* 已有账号提示 + 协议文字 */}
+          <div className="agreement-login-tip">已有账号,请登录</div>
           <div className="agreement-container">
             <div className="agreement-content">
-              <div 
+              <div
                 className={`agreement-checkbox ${agreementChecked ? 'checked' : ''}`}
                 onClick={() => setAgreementChecked(!agreementChecked)}
               />

+ 3 - 3
frontend/src/router/index.tsx

@@ -5,10 +5,10 @@ import LoadingPage from '@/pages/LoadingPage';
 import { GreetingPage } from '@/pages/GreetingPage';
 import { LumiNamingPage } from '@/pages/LumiNamingPage';
 import { ChatBotPage } from '@/pages/ChatBotPage';
-import { ErrorPage } from '@/pages/ErrorPage';
 import { RegisterPage } from '@/pages/RegisterPage';
 import { SharePage } from '@/pages/SharePage';
 import { Level5WelcomePage } from '@/pages/Level5WelcomePage';
+import { FirstLoginPage } from '@/pages/FirstLoginPage';
 import { UserGuard } from '@/components/UserGuard';
 
 const basename = import.meta.env.BASE_URL || '/';
@@ -51,8 +51,8 @@ export const router = createBrowserRouter([
         element: <Level5WelcomePage />,
       },
       {
-        path: '/error',
-        element: <ErrorPage />,
+        path: '/first-login',
+        element: <FirstLoginPage />,
       },
       {
         path: '*',

+ 2 - 0
frontend/src/utils/api.ts

@@ -186,6 +186,8 @@ export interface UserUIState {
   has_shown_cancel_tooltip: boolean;
   has_shown_send_tooltip: boolean;
   has_shown_progress_bar_tooltip: boolean;
+  has_shown_mission_stone_modal: boolean;
+  has_shown_conversation_stone_modal: boolean;
   avatar_c_unlocked: boolean;
 }
 

+ 14 - 39
frontend/src/utils/errorHandler.ts

@@ -1,38 +1,10 @@
+import { message as antMessage } from 'antd';
+
 /**
  * 错误处理工具函数
- * 统一管理错误处理和错误页面跳转
+ * 统一管理错误处理(不再跳转错误页面,只给出提示)
  */
 
-/**
- * 跳转到错误页面
- * @param error 错误对象或错误消息
- * @param options 错误页面选项
- */
-export const navigateToErrorPage = (
-  error: Error | string,
-  options?: {
-    code?: string;
-    canRetry?: boolean;
-    retryPath?: string;
-  }
-) => {
-  const errorMessage = error instanceof Error ? error.message : error;
-  const errorCode = options?.code || 'UNKNOWN';
-  const canRetry = options?.canRetry ?? false;
-  const retryPath = options?.retryPath || '/';
-
-  // 构建错误页面的 URL 参数
-  const params = new URLSearchParams({
-    message: errorMessage,
-    code: errorCode,
-    retry: canRetry.toString(),
-    retryPath,
-  });
-
-  // 使用 window.location 跳转,确保完全刷新
-  window.location.href = `/error?${params.toString()}`;
-};
-
 /**
  * 根据错误类型获取错误代码
  */
@@ -77,19 +49,22 @@ export const isRetryableError = (error: Error | string): boolean => {
 };
 
 /**
- * 处理 API 错误并跳转到错误页面
+ * 处理 API 错误:不再跳转错误页,仅给用户提示
  */
 export const handleApiError = (
   error: Error | string,
   retryPath?: string
 ) => {
   const errorCode = getErrorCode(error);
-  const canRetry = isRetryableError(error);
-  
-  navigateToErrorPage(error, {
-    code: errorCode,
-    canRetry,
-    retryPath: retryPath || '/',
-  });
+  const errorMessage = error instanceof Error ? error.message : error;
+  console.error('[handleApiError]', { errorCode, error: errorMessage, retryPath });
+
+  if (['NETWORK_ERROR', 'TIMEOUT_ERROR'].includes(errorCode)) {
+    antMessage.warning('响应超时,请重试');
+  } else if (errorCode === 'SERVER_ERROR') {
+    antMessage.error('服务器开小差了,请稍后再试');
+  } else {
+    antMessage.error('发生错误,请重试');
+  }
 };
 

+ 6 - 1
frontend/src/utils/storage.ts

@@ -14,6 +14,8 @@ export const STORAGE_KEYS = {
   HAS_SHOWN_CANCEL_TOOLTIP: 'Lyrria_has_shown_cancel_tooltip', // 是否已显示过取消按钮提示
   HAS_SHOWN_SEND_TOOLTIP: 'Lyrria_has_shown_send_tooltip', // 是否已显示过发送按钮提示
   HAS_SHOWN_PROGRESS_BAR_TOOLTIP: 'Lyrria_has_shown_progress_bar_tooltip', // 是否已显示过进度条提示
+  HAS_SHOWN_MISSION_STONE_MODAL: 'Lyrria_has_shown_mission_stone_modal', // 是否已显示过任务能量石弹窗
+  HAS_SHOWN_CONVERSATION_STONE_MODAL: 'Lyrria_has_shown_conversation_stone_modal', // 是否已显示过对话能量石弹窗
   AVATAR_C_UNLOCKED: 'Lyrria_avatar_c_unlocked', // 是否已永久展示 Avatar-C
 } as const;
 
@@ -105,13 +107,16 @@ export const isStorageAvailable = (): boolean => {
  */
 export const markRouteVisited = (route: RouteType): void => {
   try {
+    // Debug: 打印当前 route 和已有 visited 列表
+    console.log('[storage] markRouteVisited before:', route, getVisitedRoutes());
     const visited = getVisitedRoutes();
     if (!visited.includes(route)) {
       visited.push(route);
       localStorage.setItem(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(visited));
+      console.log('[storage] markRouteVisited after:', route, visited);
     }
   } catch (error) {
-    // 记录路由失败,静默处理
+    console.warn('[storage] markRouteVisited error:', error);
   }
 };