zlwu 10 месяцев назад
Родитель
Сommit
c0c9353db6

+ 90 - 28
frontend/src/components/UserGuard.tsx

@@ -1,15 +1,25 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 import { useNavigate, useLocation } from 'react-router-dom';
-import { hasUserId, clearUserRouteData, getUserLevel, getStorage, STORAGE_KEYS } from '@/utils/storage';
+import { 
+  clearStorage, 
+  getUserLevel, 
+  getStorage, 
+  getUserId,
+  STORAGE_KEYS,
+  syncUserDataFromDatabase 
+} from '@/utils/storage';
+import { getUserInfo, getUserUIState } from '@/utils/api';
 
 /**
  * 用户ID守卫组件
- * 在非首页路由检查用户ID,如果不存在则清空路由数据并跳转到首页
- * 首页路由由 RootRedirect 组件处理用户创建逻辑
+ * 在访问任何界面时:
+ * 1. 如果没有 user_id,清空 localStorage 并跳转到 /welcome
+ * 2. 如果有 user_id,检查数据库中是否存在,存在则同步数据,不存在则清空并跳转
  */
 export const UserGuard = ({ children }: { children: React.ReactNode }) => {
   const navigate = useNavigate();
   const location = useLocation();
+  const isCheckingRef = useRef(false); // 防止重复检查
 
   useEffect(() => {
     // 根路由交给 RootRedirect 处理
@@ -17,33 +27,85 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
       return;
     }
 
-    if (!hasUserId()) {
-      // 当本地没有 user_id 时,仅在需要重定向时才清理并跳转
-      // 登录流程相关页面允许无 user_id 访问
-      const allowList = ['/welcome', '/naming', '/login', '/login-verification-code', '/login-verification-input'];
-      if (!allowList.includes(location.pathname)) {
-        // 清理本地路由数据,然后跳转到 /welcome
-        clearUserRouteData();
+    // 允许无 user_id 访问的页面列表
+    const allowList = ['/welcome', '/naming', '/login', '/login-verification-code', '/login-verification-input'];
+
+    const checkAndSyncUser = async () => {
+      // 防止重复检查
+      if (isCheckingRef.current) {
+        return;
+      }
+
+      const userId = getUserId();
+
+      // 情况1: 没有 user_id
+      if (!userId) {
+        // 如果不在允许列表中,清空 localStorage 并跳转
+        if (!allowList.includes(location.pathname)) {
+          console.log('[UserGuard] 没有 user_id,清空 localStorage 并跳转到 /welcome');
+          clearStorage();
+          navigate('/welcome', { replace: true });
+        }
+        return;
+      }
+
+      // 情况2: 有 user_id,需要检查数据库中是否存在
+      isCheckingRef.current = true;
+      try {
+        console.log('[UserGuard] 检查用户是否存在并同步数据:', userId);
+        
+        // 并行获取用户信息和 UI 状态
+        const [userInfoResult, uiStateResult] = await Promise.all([
+          getUserInfo(userId).catch((error) => {
+            // 如果用户不存在(404)或其他错误,返回 null
+            console.warn('[UserGuard] 获取用户信息失败:', error);
+            return null;
+          }),
+          getUserUIState(userId).catch((error) => {
+            // UI 状态获取失败不影响主流程
+            console.warn('[UserGuard] 获取用户UI状态失败:', error);
+            return null;
+          }),
+        ]);
+
+        // 检查用户是否存在
+        if (!userInfoResult || !userInfoResult.success || !userInfoResult.data) {
+          // 用户不存在,清空 localStorage 并跳转
+          console.log('[UserGuard] 用户不存在,清空 localStorage 并跳转到 /welcome');
+          clearStorage();
+          navigate('/welcome', { replace: true });
+          return;
+        }
+
+        // 用户存在,同步数据到 localStorage
+        console.log('[UserGuard] 用户存在,同步数据到 localStorage');
+        syncUserDataFromDatabase(
+          userInfoResult.data,
+          uiStateResult?.success && uiStateResult.data ? uiStateResult.data : undefined
+        );
+
+        // 同步完成后,继续原有的等级检查逻辑
+        const userLevel = getUserLevel();
+        const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
+        const levelWelcomePages = ['/level5-welcome', '/level34-welcome'];
+        const loginFlowPages = ['/first-login', '/verification-code', '/verification-input', '/login-verification-code', '/login-verification-input', '/first-login-reward', '/login'];
+        const chatbotPage = '/chatbot';
+        
+        if (!isLoggedIn && userLevel >= 5 && !levelWelcomePages.includes(location.pathname) && !loginFlowPages.includes(location.pathname) && location.pathname !== chatbotPage) {
+          // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页或登录流程页或chatbot页,则跳转到 level5-welcome
+          navigate('/level5-welcome', { replace: true });
+        }
+      } catch (error) {
+        console.error('[UserGuard] 检查用户时发生错误:', error);
+        // 发生错误时,清空 localStorage 并跳转
+        clearStorage();
         navigate('/welcome', { replace: true });
+      } finally {
+        isCheckingRef.current = false;
       }
-      // 在允许列表上保留本地路由数据,避免刚写入就被清空
-      return;
-    }
+    };
 
-    // 检查用户等级和登录状态:如果用户等级 >= 5 且未登录,跳转到 level5-welcome
-    const userLevel = getUserLevel();
-    const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
-    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) && location.pathname !== chatbotPage) {
-      // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页或登录流程页或chatbot页,则跳转到 level5-welcome
-      // 注意:chatbot 页面的跳转由 ChatBotPage 自己处理,避免中断音频播放
-      navigate('/level5-welcome', { replace: true });
-    }
+    checkAndSyncUser();
   }, [location.pathname, navigate]);
 
   return <>{children}</>;

+ 8 - 10
frontend/src/pages/BeginPage.tsx

@@ -861,25 +861,23 @@ export const BeginPage = () => {
                     height: '16PX',
                     minWidth: '16PX',
                     borderRadius: '50%',
-                    border: '1.5PX solid rgba(245, 243, 240, 1)',
-                    background: agreementChecked ? 'rgb(145 89 240)' : 'transparent',
-                    borderColor: agreementChecked ? 'rgb(145 89 240)' : 'rgba(245, 243, 240, 1)',
+                    border: agreementChecked ? '2PX solid rgb(200, 150, 255)' : '1.5PX solid rgba(245, 243, 240, 1)',
+                    background: 'transparent',
                     cursor: 'pointer',
                     transition: 'all 0.2s ease',
                     position: 'relative',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
                   }}
                   onClick={() => setAgreementChecked(!agreementChecked)}
                 >
                   {agreementChecked && (
                     <div
                       style={{
-                        position: 'absolute',
-                        top: '50%',
-                        left: '50%',
-                        transform: 'translate(-50%, -50%)',
-                        width: '6PX',
-                        height: '6PX',
-                        background: 'white',
+                        width: '8PX',
+                        height: '8PX',
+                        background: 'rgb(200, 150, 255)',
                         borderRadius: '50%',
                       }}
                     />

+ 25 - 5
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -138,16 +138,35 @@ export const LoginVerificationInputPage = () => {
 
   // 处理验证码输入
   const handleCodeChange = (index: number, value: string) => {
-    // 只允许输入单个数字或字母
-    const sanitizedValue = value.replace(/[^0-9a-zA-Z]/g, '').slice(0, 1);
+    // 清理输入,只保留数字和字母
+    const sanitizedValue = value.replace(/[^0-9a-zA-Z]/g, '');
     
+    // 如果输入长度大于1,说明是自动填充(从输入法上方点击验证码)
+    if (sanitizedValue.length > 1) {
+      const newCodes = [...codes];
+      // 将验证码分配到4个输入框中
+      for (let i = 0; i < 4; i++) {
+        newCodes[i] = sanitizedValue[i] || '';
+      }
+      setCodes(newCodes);
+      setHasCodeError(false);
+      
+      // 聚焦到最后一个有值的输入框或最后一个输入框
+      const lastFilledIndex = Math.min(sanitizedValue.length - 1, 3);
+      codeRefs[lastFilledIndex].current?.focus();
+      setFocusedIndex(lastFilledIndex);
+      return;
+    }
+    
+    // 单个字符输入(正常输入)
+    const singleChar = sanitizedValue.slice(0, 1);
     const newCodes = [...codes];
-    newCodes[index] = sanitizedValue;
+    newCodes[index] = singleChar;
     setCodes(newCodes);
     setHasCodeError(false);
 
     // 如果输入了值,自动聚焦到下一个输入框
-    if (sanitizedValue && index < 3) {
+    if (singleChar && index < 3) {
       codeRefs[index + 1].current?.focus();
       setFocusedIndex(index + 1);
     }
@@ -449,7 +468,8 @@ export const LoginVerificationInputPage = () => {
                   ref={codeRefs[index]}
                   type="text"
                   inputMode="numeric"
-                  maxLength={1}
+                  maxLength={index === 0 ? 4 : 1}
+                  autoComplete={index === 0 ? 'one-time-code' : 'off'}
                   className="w-full h-full font-normal leading-normal tracking-[0.5px] text-[rgba(245,243,240,0.7)] bg-transparent border-none outline-none placeholder:text-[rgba(245,243,240,0.45)] text-center"
                   style={{
                     fontSize: px2vmin(20),

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

@@ -135,16 +135,35 @@ export const VerificationInputPage = () => {
 
   // 处理验证码输入
   const handleCodeChange = (index: number, value: string) => {
-    // 只允许输入单个数字或字母
-    const sanitizedValue = value.replace(/[^0-9a-zA-Z]/g, '').slice(0, 1);
+    // 清理输入,只保留数字和字母
+    const sanitizedValue = value.replace(/[^0-9a-zA-Z]/g, '');
     
+    // 如果输入长度大于1,说明是自动填充(从输入法上方点击验证码)
+    if (sanitizedValue.length > 1) {
+      const newCodes = [...codes];
+      // 将验证码分配到4个输入框中
+      for (let i = 0; i < 4; i++) {
+        newCodes[i] = sanitizedValue[i] || '';
+      }
+      setCodes(newCodes);
+      setHasCodeError(false);
+      
+      // 聚焦到最后一个有值的输入框或最后一个输入框
+      const lastFilledIndex = Math.min(sanitizedValue.length - 1, 3);
+      codeRefs[lastFilledIndex].current?.focus();
+      setFocusedIndex(lastFilledIndex);
+      return;
+    }
+    
+    // 单个字符输入(正常输入)
+    const singleChar = sanitizedValue.slice(0, 1);
     const newCodes = [...codes];
-    newCodes[index] = sanitizedValue;
+    newCodes[index] = singleChar;
     setCodes(newCodes);
     setHasCodeError(false);
 
     // 如果输入了值,自动聚焦到下一个输入框
-    if (sanitizedValue && index < 3) {
+    if (singleChar && index < 3) {
       codeRefs[index + 1].current?.focus();
       setFocusedIndex(index + 1);
     }
@@ -471,7 +490,8 @@ export const VerificationInputPage = () => {
                   ref={codeRefs[index]}
                   type="text"
                   inputMode="numeric"
-                  maxLength={1}
+                  maxLength={index === 0 ? 4 : 1}
+                  autoComplete={index === 0 ? 'one-time-code' : 'off'}
                   className="w-full h-full font-normal leading-normal tracking-[0.5px] text-[rgba(245,243,240,0.7)] bg-transparent border-none outline-none placeholder:text-[rgba(245,243,240,0.45)] text-center"
                   style={{
                     fontSize: px2vmin(20),

+ 122 - 0
frontend/src/utils/storage.ts

@@ -514,3 +514,125 @@ export const clearUserRouteData = (): void => {
   removeStorage(STORAGE_KEYS.CURRENT_PAGE);
 };
 
+/**
+ * 从数据库同步所有用户数据到 localStorage
+ * @param userInfo 用户基本信息(从 getUserInfo 获取)
+ * @param uiState 用户 UI 状态(从 getUserUIState 获取)
+ */
+export const syncUserDataFromDatabase = (
+  userInfo: {
+    user_id: string;
+    username?: string;
+    bot_name?: string;
+    user_level?: number;
+    user_type?: string;
+    current_background_image_id?: string;
+    current_background_image_metadata?: Record<string, any>;
+  },
+  uiState?: {
+    current_page?: string | null;
+    visited_routes?: string[];
+    has_shown_tap_to_chat?: boolean;
+    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;
+    has_clicked_mission_stone?: boolean;
+    has_clicked_conversation_stone?: boolean;
+    avatar_c_unlocked?: boolean;
+    has_shared_avatar?: boolean;
+    current_avatar_id?: string | null;
+  }
+): void => {
+  // 同步用户基本信息
+  if (userInfo.user_id) {
+    setUserId(userInfo.user_id);
+  }
+
+  // 同步用户名(排除后端生成的默认名称)
+  if (userInfo.username) {
+    const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());
+    if (!isBackendGeneratedName(userInfo.username)) {
+      setUserName(userInfo.username);
+    }
+  }
+
+  // 同步 Lumi 名称
+  if (userInfo.bot_name) {
+    setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.bot_name);
+  }
+
+  // 同步用户等级
+  if (typeof userInfo.user_level === 'number') {
+    setUserLevel(userInfo.user_level);
+  }
+
+  // 同步登录状态(根据 user_type 判断:registered 为已登录)
+  if (userInfo.user_type === 'registered') {
+    setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
+  } else {
+    setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
+  }
+
+  // 同步背景图片
+  if (userInfo.current_background_image_id) {
+    syncBackgroundImageToStorage(
+      userInfo.current_background_image_id,
+      userInfo.current_background_image_metadata
+    );
+  }
+
+  // 同步 UI 状态
+  if (uiState) {
+    // 同步当前页面
+    if (uiState.current_page) {
+      setStorage(STORAGE_KEYS.CURRENT_PAGE, uiState.current_page);
+    }
+
+    // 同步已访问路由
+    if (Array.isArray(uiState.visited_routes)) {
+      setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(uiState.visited_routes));
+    } else if (uiState.visited_routes === null || uiState.visited_routes === undefined) {
+      setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify([]));
+    }
+
+    // 同步各种提示状态
+    if (uiState.has_shown_tap_to_chat) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
+    }
+    if (uiState.has_shown_cancel_tooltip) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
+    }
+    if (uiState.has_shown_send_tooltip) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
+    }
+    if (uiState.has_shown_progress_bar_tooltip) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
+    }
+    if (uiState.has_shown_mission_stone_modal) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
+    }
+    if (uiState.has_shown_conversation_stone_modal) {
+      setStorage(STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL, 'true');
+    }
+    if (uiState.has_clicked_mission_stone) {
+      setStorage(STORAGE_KEYS.HAS_CLICKED_MISSION_STONE, 'true');
+    }
+    if (uiState.has_clicked_conversation_stone) {
+      setStorage(STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE, 'true');
+    }
+    if (uiState.avatar_c_unlocked) {
+      setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
+    }
+
+    // 同步分享状态(无论 true/false 都同步)
+    setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, uiState.has_shared_avatar ? 'true' : 'false');
+
+    // 同步当前形象ID
+    if (uiState.current_avatar_id) {
+      setCurrentAvatarId(uiState.current_avatar_id);
+    }
+  }
+};
+