Browse Source

提交代码

zlwu 10 months ago
parent
commit
4ac6ded886

+ 84 - 27
backend/main.py

@@ -1144,10 +1144,13 @@ async def wechat_callback(
 
 
 @app.get("/api/auth/qq/authorize")
-async def qq_authorize(request: Request):
+async def qq_authorize(request: Request, from_page: str = Query(None, alias="from", description="来源页面")):
     """
     获取QQ授权URL
     
+    Args:
+        from_page: 来源页面(如 first-login),用于回调时决定重定向位置
+    
     Returns:
         重定向到QQ授权页面
     """
@@ -1156,14 +1159,22 @@ async def qq_authorize(request: Request):
     
     # 生成state参数(用于防止CSRF攻击)
     import secrets
-    state = secrets.token_urlsafe(32)
+    state_token = secrets.token_urlsafe(32)
     
-    # 将 state 存储到 Redis 以便回调校验
+    # 如果有来源页面,编码到 state 中(格式:{token}_{from})
+    if from_page:
+        state = f"{state_token}_{from_page}"
+    else:
+        state = state_token
+    
+    # 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
     redis_cache = getattr(request.app.state, 'redis_cache', None)
     if redis_cache:
         try:
             redis_client = await redis_cache._get_redis()
-            await redis_client.setex(f"oauth:qq:state:{state}", 600, "1")
+            # 存储原始 token 和来源信息
+            state_data = {"token": state_token, "from": from_page} if from_page else {"token": state_token}
+            await redis_client.setex(f"oauth:qq:state:{state_token}", 600, json.dumps(state_data))
         except Exception as e:
             logger.warning(f"存储 QQ state 失败,将跳过校验: {e}")
     
@@ -1184,7 +1195,7 @@ async def qq_authorize(request: Request):
     auth_url = f"https://graph.qq.com/oauth2.0/authorize?{urllib.parse.urlencode(params)}"
     
     # 调试日志:记录实际发送的授权URL(隐藏敏感信息)
-    logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}")
+    logger.info(f"🔍 QQ授权URL生成 - AppID: {settings.QQ_APP_ID[:8]}..., 回调地址: {redirect_uri}, 来源: {from_page}")
     
     return RedirectResponse(url=auth_url)
 
@@ -1206,19 +1217,36 @@ async def qq_callback(
         重定向到前端页面(带token)
     """
     try:
-        # 0. 验证 state 防止 CSRF
+        # 0. 验证 state 防止 CSRF,并提取来源信息
+        from_page = None
+        state_token = state
         if state:
+            # 解析 state(格式可能是 {token} 或 {token}_{from})
+            if "_" in state:
+                parts = state.split("_", 1)
+                state_token = parts[0]
+                from_page = parts[1] if len(parts) > 1 else None
+            else:
+                state_token = state
+            
             redis_cache = getattr(request.app.state, 'redis_cache', None)
             if redis_cache:
                 try:
                     redis_client = await redis_cache._get_redis()
-                    state_key = f"oauth:qq:state:{state}"
-                    exists = await redis_client.exists(state_key)
-                    if not exists:
-                        logger.warning(f"QQ state 验证失败: {state}")
+                    state_key = f"oauth:qq:state:{state_token}"
+                    state_data_str = await redis_client.get(state_key)
+                    if not state_data_str:
+                        logger.warning(f"QQ state 验证失败: {state_token}")
                         return RedirectResponse(
                             url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
                         )
+                    # 如果存储的是 JSON 格式,解析获取来源信息
+                    try:
+                        state_data = json.loads(state_data_str)
+                        from_page = state_data.get("from") or from_page
+                    except (json.JSONDecodeError, TypeError):
+                        # 如果不是 JSON 格式,使用从 state 中解析的 from_page
+                        pass
                     await redis_client.delete(state_key)
                 except Exception as state_error:
                     logger.warning(f"QQ state 验证异常: {state_error},继续流程")
@@ -1335,26 +1363,55 @@ async def qq_callback(
         except Exception as info_error:
             logger.warning(f"拉取QQ用户信息异常: {info_error}")
         
-        # 3. 检查用户是否存在
-        user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
-        
-        if not user_exists:
-            # 用户不存在,返回 welcome 页面并提示
-            logger.info(f"⚠️ QQ用户未注册: openid={openid}")
-            return RedirectResponse(
-                url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
+        # 3. 根据来源页面决定处理逻辑
+        if from_page == "login":
+            # 从 login 页面来的,需要先检查用户是否存在
+            # 如果用户不存在,不允许自动注册,应该提示用户先注册
+            user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
+            
+            if not user_exists:
+                # 用户不存在,重定向到 welcome 页面并提示
+                logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid}")
+                return RedirectResponse(
+                    url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
+                )
+            
+            # 用户存在,使用openid登录
+            user = await user_service.login_with_qq(
+                openid=openid,
+                username=nickname,
+                unionid=unionid,
             )
+            
+            # 重定向到聊天界面
+            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            return RedirectResponse(url=frontend_url)
         
-        # 4. 用户存在,使用openid登录
-        user = await user_service.login_with_qq(
-            openid=openid,
-            username=nickname,
-            unionid=unionid,
-        )
+        elif from_page == "first-login":
+            # 从 first-login 页面来的,允许自动注册
+            # 使用openid登录/注册(login_with_qq 会自动创建新用户)
+            user = await user_service.login_with_qq(
+                openid=openid,
+                username=nickname,
+                unionid=unionid,
+            )
+            
+            # 重定向到 first-login-reward
+            frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            return RedirectResponse(url=frontend_url)
         
-        # 5. 重定向到聊天界面,携带用户信息
-        frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
-        return RedirectResponse(url=frontend_url)
+        else:
+            # 其他来源(如 register 页面),允许自动注册
+            # 使用openid登录/注册(login_with_qq 会自动创建新用户)
+            user = await user_service.login_with_qq(
+                openid=openid,
+                username=nickname,
+                unionid=unionid,
+            )
+            
+            # 默认重定向到聊天界面
+            frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+            return RedirectResponse(url=frontend_url)
         
     except Exception as e:
         logger.error(f"QQ OAuth回调处理失败: {e}")

+ 5 - 2
frontend/src/components/UserGuard.tsx

@@ -36,9 +36,12 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
     const levelWelcomePages = ['/level5-welcome', '/level34-welcome'];
     // 登录流程相关页面,不应该被重定向回 level5-welcome
     const loginFlowPages = ['/first-login', '/verification-code', '/verification-input', '/login-verification-code', '/login-verification-input', '/first-login-reward', '/login'];
+    // chatbot 页面由 ChatBotPage 自己处理跳转逻辑(需要等待音频播放完成)
+    const chatbotPage = '/chatbot';
     
-    if (!isLoggedIn && userLevel >= 5 && !levelWelcomePages.includes(location.pathname) && !loginFlowPages.includes(location.pathname)) {
-      // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页或登录流程页,则跳转到 level5-welcome
+    if (!isLoggedIn && userLevel >= 5 && !levelWelcomePages.includes(location.pathname) && !loginFlowPages.includes(location.pathname) && location.pathname !== chatbotPage) {
+      // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页或登录流程页或chatbot页,则跳转到 level5-welcome
+      // 注意:chatbot 页面的跳转由 ChatBotPage 自己处理,避免中断音频播放
       navigate('/level5-welcome', { replace: true });
     }
   }, [location.pathname, navigate]);

+ 148 - 21
frontend/src/pages/ChatBotPage.tsx

@@ -141,23 +141,12 @@ export const ChatBotPage = () => {
   // 初始化时检查 localStorage,确保第一次打开时立即显示提示
   const [showTapToChat, setShowTapToChat] = useState(() => {
     const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
-    if (!hasShown) {
-      // 第一次打开,立即保存标记并显示
-      setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
-
-      // 同步到后端 UI 状态(如果已有 user_id)
-      const userId = getUserId();
-      if (userId) {
-        updateUserUIState(userId, {
-          has_shown_tap_to_chat: true,
-        }).catch(() => {
-          // 同步失败不影响前端提示展示
-        });
-      }
-
-      return true;
+    // 如果已经显示过,直接返回 false
+    if (hasShown) {
+      return false;
     }
-    return false;
+    // 第一次打开,返回 true(但不立即保存到 localStorage,等 WebSocket 连接成功后再保存)
+    return true;
   });
   const sendingTimeoutRef = useRef<number | null>(null);
   const updateRequestId = useCallback(
@@ -175,9 +164,63 @@ export const ChatBotPage = () => {
     const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
     if (!isLoggedIn && userLevelValue >= 5 && !levelRedirectRef.current) {
       levelRedirectRef.current = true;
-      navigate('/level5-welcome');
+      
+      // 等待音频播放完成后再跳转
+      const performRedirect = async () => {
+        // 检查是否正在等待AI回复(sending状态)或正在播放音频(playing状态)
+        const isWaitingOrPlaying = currentState === 'sending' || currentState === 'playing';
+        const hasActiveRequest = currentRequestId !== null;
+        const isAudioPlaying = playerRef.current?.isPlaying() || false;
+        
+        if (isWaitingOrPlaying || hasActiveRequest || isAudioPlaying) {
+          console.log('[ChatBotPage] 检测到正在等待AI回复或播放音频,等待完成后再跳转...', {
+            state: currentState,
+            hasRequest: hasActiveRequest,
+            isPlaying: isAudioPlaying
+          });
+          
+          // 如果正在等待回复(sending状态),等待状态变为playing或idle
+          if (currentState === 'sending' || hasActiveRequest) {
+            await new Promise<void>((resolve) => {
+              // 设置超时保护,最多等待30秒
+              const timeoutId = setTimeout(() => {
+                console.warn('[ChatBotPage] 等待AI回复超时,继续执行跳转');
+                resolve();
+              }, 30000);
+              
+              // 使用轮询检查状态,直到状态变为idle且没有活跃请求
+              const checkInterval = setInterval(() => {
+                const appState = useAppStore.getState();
+                // 如果状态变为idle且没有活跃请求,说明对话已完成
+                if (appState.currentState === 'idle' && appState.currentRequestId === null) {
+                  clearInterval(checkInterval);
+                  clearTimeout(timeoutId);
+                  resolve();
+                }
+              }, 100);
+            });
+          }
+          
+          // 如果音频正在播放或状态为playing,等待播放完成
+          if (currentState === 'playing' || isAudioPlaying || playerRef.current?.isPlaying()) {
+            if (playerRef.current) {
+              try {
+                await playerRef.current.waitForPlaybackComplete();
+                console.log('[ChatBotPage] 音频播放完成');
+              } catch (error) {
+                console.warn('[ChatBotPage] 等待音频播放完成时出错:', error);
+              }
+            }
+          }
+          
+          console.log('[ChatBotPage] 所有等待完成,执行跳转');
+        }
+        navigate('/level5-welcome');
+      };
+      
+      performRedirect();
     }
-  }, [userLevelValue, navigate]);
+  }, [userLevelValue, navigate, currentState, currentRequestId]);
   const [showCancelTooltip, setShowCancelTooltip] = useState(false);
   const [showSendTooltip, setShowSendTooltip] = useState(false);
   const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
@@ -240,7 +283,7 @@ export const ChatBotPage = () => {
   });
   // GIF 切换定时器
   const emotionGifTimerRef = useRef<number | null>(null);
-  const handleAvatarClick = useCallback(() => {
+  const handleAvatarClick = useCallback(async () => {
     if (!isAvatarCUnlocked) {
       return;
     }
@@ -253,6 +296,18 @@ export const ChatBotPage = () => {
     // 未登录:根据用户等级跳转
     if (userLevelValue >= 5) {
       levelRedirectRef.current = true;
+      
+      // 等待音频播放完成后再跳转
+      if (playerRef.current?.isPlaying()) {
+        console.log('[ChatBotPage] 检测到音频正在播放,等待播放完成后再跳转...');
+        try {
+          await playerRef.current.waitForPlaybackComplete();
+          console.log('[ChatBotPage] 音频播放完成,执行跳转');
+        } catch (error) {
+          console.warn('[ChatBotPage] 等待音频播放完成时出错:', error);
+        }
+      }
+      
       navigate('/level5-welcome');
       return;
     }
@@ -326,6 +381,29 @@ export const ChatBotPage = () => {
   const showNegativeGif = useCallback(() => setGifByState('negative'), [setGifByState]);
   const showTalkingGif = useCallback(() => setGifByState('talking'), [setGifByState]);
 
+  // 预加载动画形象的 GIF
+  const preloadAvatarGifs = useCallback(
+    (avatarId: string | null) => {
+      const gifSet = resolveAvatarGifSet(avatarId);
+      const gifUrls = [gifSet.default, gifSet.positive, gifSet.negative, gifSet.talking];
+      
+      // 使用 Image 对象预加载所有 GIF
+      gifUrls.forEach((url) => {
+        if (url) {
+          const img = new Image();
+          img.src = url;
+          // 静默处理加载错误,避免影响主流程
+          img.onerror = () => {
+            console.warn(`[预加载] GIF 加载失败: ${url}`);
+          };
+        }
+      });
+      
+      console.log(`[预加载] 已开始预加载形象 ${avatarId || 'default'} 的所有 GIF`);
+    },
+    [resolveAvatarGifSet]
+  );
+
   // 拉取头像 GIF 配置
   useEffect(() => {
     const fetchAvatarGifs = async () => {
@@ -358,6 +436,15 @@ export const ChatBotPage = () => {
     fetchAvatarGifs();
   }, []);
 
+  // 当 avatarGifSets 更新或 currentAvatarId 变化时,预加载当前形象的 GIF
+  useEffect(() => {
+    if (Object.keys(avatarGifSets).length > 0) {
+      // 如果 currentAvatarId 为 null,预加载默认形象
+      const avatarIdToPreload = currentAvatarId || AVATAR_ID_DEFAULT;
+      preloadAvatarGifs(avatarIdToPreload);
+    }
+  }, [avatarGifSets, currentAvatarId, preloadAvatarGifs]);
+
   // 监听 storage(跨标签页同步当前形象)
   useEffect(() => {
     const handleStorage = (event: StorageEvent) => {
@@ -864,7 +951,11 @@ export const ChatBotPage = () => {
           }
           if (state.has_shown_tap_to_chat) {
             setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
-            setShowTapToChat(false);
+            // 只有在 WebSocket 已连接时才隐藏提示(确保用户能看到提示)
+            // 如果 WebSocket 还未连接,保持 showTapToChat 为 true,等连接成功后再隐藏
+            if (isConnected) {
+              setShowTapToChat(false);
+            }
           }
           if (state.has_shown_cancel_tooltip) {
             setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
@@ -1052,6 +1143,28 @@ export const ChatBotPage = () => {
             // 记录WebSocket连接建立时间
             performanceLogger.recordWebSocketConnected();
             
+            // 如果这是首次连接成功,处理 TapToChat 提示
+            if (!connectionLostRef.current) {
+              const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
+              if (!hasShown && showTapToChat) {
+                // 第一次连接成功,保存标记
+                setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
+                
+                // 同步到后端 UI 状态(如果已有 user_id)
+                const userId = getUserId();
+                if (userId) {
+                  updateUserUIState(userId, {
+                    has_shown_tap_to_chat: true,
+                  }).catch(() => {
+                    // 同步失败不影响前端提示展示
+                  });
+                }
+              } else if (hasShown) {
+                // 如果已经标记为已显示(可能是从后端同步的),现在 WebSocket 已连接,可以安全隐藏
+                setShowTapToChat(false);
+              }
+            }
+            
             if (connectionLostRef.current) {
               antMessage.success('语音服务已恢复,可以继续对话');
               connectionLostRef.current = false;
@@ -1381,9 +1494,23 @@ export const ChatBotPage = () => {
     
     // 其他状态(idle/error/sending)都允许开始录音
 
-    // 首次点击录音按钮,隐藏 TapToChat 提示
+    // 首次点击录音按钮,隐藏 TapToChat 提示并保存标记
     if (showTapToChat) {
       setShowTapToChat(false);
+      // 确保标记已保存(防止 WebSocket 连接较慢时未保存)
+      const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
+      if (!hasShown) {
+        setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
+        // 同步到后端 UI 状态(如果已有 user_id)
+        const userId = getUserId();
+        if (userId) {
+          updateUserUIState(userId, {
+            has_shown_tap_to_chat: true,
+          }).catch(() => {
+            // 同步失败不影响主流程
+          });
+        }
+      }
     }
 
     // 点击录音按钮时,隐藏进度条提示

+ 84 - 3
frontend/src/pages/FirstLoginPage.tsx

@@ -1,7 +1,8 @@
 import { useEffect, useState, useRef } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState } from '@/utils/api';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { message } from 'antd';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } from '@/utils/storage';
+import { updateUserUIState, getQQAuthUrl, getUserInfo } from '@/utils/api';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -23,6 +24,7 @@ interface LocationState {
 export const FirstLoginPage = () => {
   const location = useLocation();
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const state = location.state as LocationState | null;
 
   // 根据传入的数据初始化状态
@@ -55,6 +57,77 @@ export const FirstLoginPage = () => {
     }
   }, []);
 
+  // 处理QQ OAuth回调
+  useEffect(() => {
+    const oauth = searchParams.get('oauth');
+    const userId = searchParams.get('user_id');
+    const usernameParam = searchParams.get('username');
+    const error = searchParams.get('error');
+
+    if (error === 'qq_auth_failed') {
+      message.error('QQ登录失败,请重试');
+      // 清除URL参数
+      navigate('/first-login', { replace: true });
+      return;
+    }
+
+    if (error === 'user_not_registered' && oauth === 'qq') {
+      // 用户不存在,但这是从 first-login 来的,应该自动注册
+      // 这个情况应该由后端处理,如果后端没有处理,这里可以提示用户
+      message.error('QQ用户未注册,请使用其他方式登录');
+      navigate('/first-login', { replace: true });
+      return;
+    }
+
+    if (oauth === 'qq' && userId && usernameParam) {
+      // QQ登录/注册成功,保存用户信息并完成登录流程
+      const handleQQLoginSuccess = async () => {
+        try {
+          // 保存基本用户信息
+          setStorage(STORAGE_KEYS.USER_ID, userId);
+          setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
+          
+          // 设置登录状态
+          setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+          
+          // 获取完整用户信息并同步到 localStorage(刷新数据)
+          try {
+            const userInfo = await getUserInfo(userId);
+            if (userInfo.success && userInfo.data) {
+              // 同步用户名
+              if (userInfo.data.username) {
+                setUserName(userInfo.data.username);
+              }
+              // 同步机器人名字
+              if (userInfo.data.bot_name) {
+                setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
+              }
+              // 同步用户等级
+              if (typeof userInfo.data.user_level === 'number') {
+                setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfo.data.user_level));
+              }
+              // 背景图片会在 getUserInfo 中自动同步
+            }
+          } catch (error) {
+            // 获取完整用户信息失败不影响登录流程,静默处理
+            console.warn('获取完整用户信息失败:', error);
+          }
+          
+          message.success('QQ登录成功!已获得帽子奖励');
+          
+          // 清除URL参数并跳转到奖励页面
+          navigate('/first-login-reward', { replace: true });
+        } catch (error) {
+          console.error('处理QQ登录成功回调失败:', error);
+          message.error('登录处理失败,请重试');
+          navigate('/first-login', { replace: true });
+        }
+      };
+
+      handleQQLoginSuccess();
+    }
+  }, [searchParams, navigate]);
+
   return (
     <div>
       {/* 顶部进度条图片 */}
@@ -320,6 +393,14 @@ export const FirstLoginPage = () => {
                 email,
               },
             });
+          } else if (selectedLoginType === 'qq') {
+            // QQ登录:跳转到QQ授权页面(传递来源信息)
+            try {
+              const qqAuthUrl = getQQAuthUrl('first-login');
+              window.location.href = qqAuthUrl;
+            } catch (error) {
+              message.error('QQ登录失败,请重试');
+            }
           }
         }}
         style={{

+ 2 - 2
frontend/src/pages/LoginPage.tsx

@@ -122,8 +122,8 @@ export const LoginPage = () => {
     }
     // QQ登录
     else if (selectedLoginType === 'qq') {
-      // 跳转到QQ授权页面
-      const qqAuthUrl = getQQAuthUrl();
+      // 跳转到QQ授权页面(传递来源信息,标识这是登录页面)
+      const qqAuthUrl = getQQAuthUrl('login');
       window.location.href = qqAuthUrl;
     }
   };

+ 32 - 5
frontend/src/pages/VerificationInputPage.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
 import { message } from 'antd';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState, verifyCode, sendCode, registerWithPhone, registerWithEmail } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } from '@/utils/storage';
+import { updateUserUIState, verifyCode, sendCode, registerWithPhone, registerWithEmail, getUserInfo } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -247,14 +247,41 @@ export const VerificationInputPage = () => {
       }
 
       if (response.success && response.data) {
-        // 保存用户信息到 localStorage(如果 user_id 发生变化则更新)
-        if (response.data.user_id) {
-          setStorage(STORAGE_KEYS.USER_ID, response.data.user_id);
+        // 保存基本用户信息到 localStorage
+        const userId = response.data.user_id;
+        if (userId) {
+          setStorage(STORAGE_KEYS.USER_ID, userId);
         }
         if (response.data.username) {
           setStorage(STORAGE_KEYS.USER_NAME, response.data.username);
         }
         
+        // 设置登录状态为已登录
+        setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+        
+        // 获取完整用户信息并同步到 localStorage(刷新数据)
+        try {
+          const userInfo = await getUserInfo(userId);
+          if (userInfo.success && userInfo.data) {
+            // 同步用户名
+            if (userInfo.data.username) {
+              setUserName(userInfo.data.username);
+            }
+            // 同步机器人名字
+            if (userInfo.data.bot_name) {
+              setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
+            }
+            // 同步用户等级
+            if (typeof userInfo.data.user_level === 'number') {
+              setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfo.data.user_level));
+            }
+            // 背景图片会在 getUserInfo 中自动同步
+          }
+        } catch (error) {
+          // 获取完整用户信息失败不影响注册流程,静默处理
+          console.warn('获取完整用户信息失败:', error);
+        }
+        
         message.success('注册成功!已获得帽子奖励');
         
         // 跳转到奖励页面

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

@@ -550,9 +550,13 @@ export const getWechatAuthUrl = (): string => {
 };
 
 // 获取QQ授权URL
-export const getQQAuthUrl = (): string => {
+export const getQQAuthUrl = (from?: string): string => {
   const apiBaseUrl = getApiBaseUrl();
-  return `${apiBaseUrl}/api/auth/qq/authorize`;
+  const url = `${apiBaseUrl}/api/auth/qq/authorize`;
+  if (from) {
+    return `${url}?from=${encodeURIComponent(from)}`;
+  }
+  return url;
 };
 
 /**

+ 45 - 0
frontend/src/utils/audioPlayer.ts

@@ -218,6 +218,51 @@ export class AudioPlayer {
     this.onEndCallback = callback;
   }
 
+  /**
+   * 检查是否正在播放音频
+   * @returns true 如果正在播放或队列中有待播放的音频
+   */
+  isPlaying(): boolean {
+    return this.currentSources.length > 0 || this.audioQueue.length > 0;
+  }
+
+  /**
+   * 等待音频播放完成
+   * @param timeout 超时时间(毫秒),默认30秒
+   * @returns Promise,在播放完成时resolve
+   */
+  async waitForPlaybackComplete(timeout: number = 30000): Promise<void> {
+    // 如果当前没有播放,立即resolve
+    if (!this.isPlaying()) {
+      return;
+    }
+
+    return new Promise((resolve) => {
+      // 保存原始回调
+      const originalCallback = this.onEndCallback;
+
+      // 设置超时
+      const timeoutId = setTimeout(() => {
+        // 超时后仍然resolve,避免阻塞跳转
+        console.warn('[AudioPlayer] 等待音频播放完成超时,继续执行跳转');
+        // 恢复原始回调
+        this.onEndCallback = originalCallback;
+        resolve();
+      }, timeout);
+
+      // 设置临时回调
+      this.onEndCallback = () => {
+        clearTimeout(timeoutId);
+        // 恢复原始回调
+        this.onEndCallback = originalCallback;
+        if (originalCallback) {
+          originalCallback();
+        }
+        resolve();
+      };
+    });
+  }
+
   async release(): Promise<void> {
     this.stop();