zlwu 10 meses atrás
pai
commit
d798419796

+ 136 - 30
frontend/src/main.tsx

@@ -42,7 +42,7 @@ const installViewportHeightFix = () => {
   };
 
   // 强制锁定视口高度(立即执行,无防抖)
-  const forceLockViewportHeight = () => {
+  const forceLockViewportHeight = (reason = 'unknown') => {
     if (initialHeight === null) {
       // 使用 window.innerHeight 作为初始高度(更稳定)
       initialHeight = window.innerHeight;
@@ -50,6 +50,7 @@ const installViewportHeightFix = () => {
     isLocked = true;
     const vh = initialHeight * 0.01;
     document.documentElement.style.setProperty('--vh', `${vh}px`);
+    console.log(`[ViewportFix] 🔒 锁定视口高度: initialHeight=${initialHeight}px, --vh=${vh}px, reason=${reason}, isInputFocused=${isInputFocused}`);
   };
 
   // 设置视口高度(带防抖)
@@ -57,7 +58,15 @@ const installViewportHeightFix = () => {
     const updateHeight = () => {
       // 如果已锁定,完全不更新(防止键盘弹出时界面缩小)
       if (isLocked && initialHeight !== null) {
-        forceLockViewportHeight();
+        console.log(`[ViewportFix] 🔧 setViewportHeight: 已锁定,跳过更新, initialHeight=${initialHeight}px`);
+        forceLockViewportHeight('setViewportHeight-locked');
+        return;
+      }
+      
+      // 如果输入框处于焦点状态,即使未锁定也应该锁定(防止键盘弹出时界面缩小)
+      if (isInputFocused && initialHeight !== null) {
+        console.log(`[ViewportFix] 🔧 setViewportHeight: 输入框聚焦中,强制锁定, initialHeight=${initialHeight}px`);
+        forceLockViewportHeight('setViewportHeight-input-focused');
         return;
       }
       
@@ -67,23 +76,35 @@ const installViewportHeightFix = () => {
       // 检测键盘是否弹出(高度突然大幅减小,通常是键盘弹出)
       if (!skipKeyboardCheck && initialHeight !== null) {
         const heightDiff = initialHeight - currentHeight;
-        // 降低阈值到 100px,覆盖更多设备
-        if (heightDiff > 100) {
+        // 降低阈值到 50px,更早检测到键盘弹出(特别是 inputMode="numeric" 等快速键盘)
+        if (heightDiff > 50) {
           // 键盘弹出,立即锁定
-          forceLockViewportHeight();
+          console.log(`[ViewportFix] 🔧 setViewportHeight: 检测到键盘弹出(heightDiff>50), 立即锁定`);
+          forceLockViewportHeight('setViewportHeight-keyboard-detected');
           return;
         } else if (heightDiff < -50) {
           // 高度增加,可能是键盘收起
           // 只有在输入框未聚焦时才解锁
           if (!isInputFocused) {
+            console.log(`[ViewportFix] 🔧 setViewportHeight: 键盘收起,解锁, heightDiff=${heightDiff}px`);
             isLocked = false;
-            initialHeight = currentHeight;
+            // 关键修复:只有在高度明显增加时才更新 initialHeight(避免使用错误的高度值)
+            if (currentHeight > initialHeight + 50) {
+              console.log(`[ViewportFix] 🔧 setViewportHeight: 更新初始高度(键盘收起), old=${initialHeight}px, new=${currentHeight}px`);
+              initialHeight = currentHeight;
+            }
           }
         }
       }
 
-      // 记录初始高度(首次加载或键盘收起后)
-      if (initialHeight === null || (!isLocked && !skipKeyboardCheck && !isInputFocused)) {
+      // 记录初始高度(仅在首次加载时,或明确要求更新时)
+      // 关键修复:不要在键盘收起时自动更新 initialHeight,避免使用错误的高度值
+      if (initialHeight === null) {
+        console.log(`[ViewportFix] 🔧 setViewportHeight: 记录初始高度, baseHeight=${baseHeight}px`);
+        initialHeight = baseHeight;
+      } else if (skipKeyboardCheck && !isInputFocused) {
+        // 只有在明确跳过键盘检查且输入框未聚焦时,才允许更新 initialHeight(用于横竖屏切换等场景)
+        console.log(`[ViewportFix] 🔧 setViewportHeight: 更新初始高度(明确要求), baseHeight=${baseHeight}px`);
         initialHeight = baseHeight;
       }
 
@@ -92,6 +113,7 @@ const installViewportHeightFix = () => {
       const vh = height * 0.01;
       // 全局 1vh 的真实像素值
       document.documentElement.style.setProperty('--vh', `${vh}px`);
+      console.log(`[ViewportFix] 🔧 setViewportHeight: 更新 --vh, height=${height}px, --vh=${vh}px, isLocked=${isLocked}, skipKeyboardCheck=${skipKeyboardCheck}`);
     };
 
     // 防抖处理(除非立即执行)
@@ -112,11 +134,12 @@ const installViewportHeightFix = () => {
     }
     isInputFocused = true;
     // 立即锁定,不使用防抖
-    forceLockViewportHeight();
+    forceLockViewportHeight('focusin');
   };
 
   // 解锁视口高度(输入框失去焦点时,延迟恢复)
   const unlockViewportHeight = () => {
+    console.log(`[ViewportFix] 🔓 unlockViewportHeight: 输入框失去焦点, 延迟300ms后解锁, currentInitialHeight=${initialHeight}px`);
     isInputFocused = false;
     // 清除之前的恢复定时器
     if (blurRecoveryTimer) clearTimeout(blurRecoveryTimer);
@@ -124,7 +147,15 @@ const installViewportHeightFix = () => {
     blurRecoveryTimer = setTimeout(() => {
       // 更新初始高度为当前高度(键盘已收起)
       const currentHeight = getCurrentHeight();
-      initialHeight = currentHeight;
+      const oldInitialHeight = initialHeight;
+      // 关键修复:只有在当前高度明显大于之前的初始高度时,才更新(说明键盘已收起)
+      // 避免在键盘弹出过程中错误更新 initialHeight
+      if (initialHeight !== null && currentHeight > initialHeight + 50) {
+        console.log(`[ViewportFix] 🔓 unlockViewportHeight: 键盘已收起,更新初始高度, old=${oldInitialHeight}px, new=${currentHeight}px`);
+        initialHeight = currentHeight;
+      } else {
+        console.log(`[ViewportFix] 🔓 unlockViewportHeight: 键盘可能未完全收起,保持初始高度, initialHeight=${initialHeight}px, currentHeight=${currentHeight}px`);
+      }
       isLocked = false;
       setViewportHeight(false, true);
     }, 300);
@@ -133,23 +164,26 @@ const installViewportHeightFix = () => {
   // 页面加载完成后立即记录初始高度
   const initializeHeight = () => {
     // 立即记录初始高度(使用 window.innerHeight,更稳定)
-    const recordInitialHeight = () => {
+    const recordInitialHeight = (source: string) => {
       if (initialHeight === null) {
         initialHeight = window.innerHeight;
-        setViewportHeight(false, true);
+        // 立即设置 --vh,确保在自动聚焦前就已完成初始化
+        const vh = initialHeight * 0.01;
+        document.documentElement.style.setProperty('--vh', `${vh}px`);
+        console.log(`[ViewportFix] 🚀 初始化高度: source=${source}, initialHeight=${initialHeight}px, --vh=${vh}px, readyState=${document.readyState}`);
       }
     };
     
     // 如果 DOM 已加载完成,立即初始化
     if (document.readyState === 'complete' || document.readyState === 'interactive') {
-      recordInitialHeight();
+      recordInitialHeight('readyState-complete/interactive');
     } else {
       // 立即执行一次(尽早记录)
-      recordInitialHeight();
+      recordInitialHeight('immediate');
       
       // 同时等待 DOM 加载完成,再次确认
       const handleDOMReady = () => {
-        recordInitialHeight();
+        recordInitialHeight('DOMContentLoaded');
         document.removeEventListener('DOMContentLoaded', handleDOMReady);
       };
       document.addEventListener('DOMContentLoaded', handleDOMReady);
@@ -164,7 +198,9 @@ const installViewportHeightFix = () => {
     requestAnimationFrame(() => {
       if (initialHeight === null) {
         initialHeight = window.innerHeight;
-        setViewportHeight(false, true);
+        const vh = initialHeight * 0.01;
+        document.documentElement.style.setProperty('--vh', `${vh}px`);
+        console.log(`[ViewportFix] 🚀 初始化高度: source=requestAnimationFrame, initialHeight=${initialHeight}px, --vh=${vh}px`);
       }
     });
   };
@@ -180,14 +216,35 @@ const installViewportHeightFix = () => {
   };
 
   // 监听输入框触摸开始事件(在 focusin 之前触发,更早锁定)
+  // 增强版:检查触摸目标及其父元素,确保能捕获到所有输入框场景
   const handleTouchStart = (e: TouchEvent) => {
     const target = e.target as HTMLElement;
+    
+    // 检查触摸目标是否为输入框
     if (isInputElement(target)) {
+      console.log(`[ViewportFix] 👆 touchstart: 检测到输入框, tagName=${target.tagName}, type=${(target as HTMLInputElement).type}, inputMode=${(target as HTMLInputElement).inputMode}, initialHeight=${initialHeight}, isLocked=${isLocked}`);
       // 立即锁定,防止键盘弹出时界面缩小
       if (initialHeight === null) {
         initialHeight = window.innerHeight;
       }
-      forceLockViewportHeight();
+      forceLockViewportHeight('touchstart');
+      return;
+    }
+    
+    // 检查触摸目标的父元素(某些情况下输入框可能被其他元素包裹)
+    let parent = target.parentElement;
+    let depth = 0;
+    while (parent && depth < 3) {
+      if (isInputElement(parent)) {
+        console.log(`[ViewportFix] 👆 touchstart: 在父元素(depth=${depth})检测到输入框, tagName=${parent.tagName}, initialHeight=${initialHeight}, isLocked=${isLocked}`);
+        if (initialHeight === null) {
+          initialHeight = window.innerHeight;
+        }
+        forceLockViewportHeight('touchstart-parent');
+        return;
+      }
+      parent = parent.parentElement;
+      depth++;
     }
   };
 
@@ -195,6 +252,8 @@ const installViewportHeightFix = () => {
   const handleFocusIn = (e: FocusEvent) => {
     const target = e.target as HTMLElement;
     if (isInputElement(target)) {
+      const input = target as HTMLInputElement;
+      console.log(`[ViewportFix] 🎯 focusin: 输入框获得焦点, tagName=${target.tagName}, type=${input.type}, inputMode=${input.inputMode}, initialHeight=${initialHeight}, isLocked=${isLocked}, currentHeight=${getCurrentHeight()}`);
       lockViewportHeight();
     }
   };
@@ -217,40 +276,87 @@ const installViewportHeightFix = () => {
 
   // 监听 visualViewport 变化(移动端浏览器地址栏显示/隐藏时会触发)
   // 关键优化:在 visualViewport.resize 时,如果检测到高度大幅减小,立即锁定
+  // 增强版:更早检测键盘弹出,修复 inputMode="numeric" 等快速键盘场景
   if (window.visualViewport) {
+    // 记录上一次的高度,用于检测高度变化趋势
+    let lastHeight: number | null = null;
+    
     window.visualViewport.addEventListener('resize', () => {
+      const currentHeight = getCurrentHeight();
+      const activeElement = document.activeElement as HTMLElement;
+      const isActiveInput = isInputElement(activeElement);
+      
       // 如果已锁定,完全不处理(防止任何更新)
       if (isLocked && initialHeight !== null) {
-        forceLockViewportHeight();
+        console.log(`[ViewportFix] 📐 visualViewport.resize: 已锁定,跳过处理, currentHeight=${currentHeight}px, initialHeight=${initialHeight}px`);
+        forceLockViewportHeight('already-locked');
         return;
       }
       
-      const currentHeight = getCurrentHeight();
+      // 如果 initialHeight 还未初始化,先记录初始高度
+      // 这是关键修复:防止在初始化完成前就触发键盘导致错误更新
+      if (initialHeight === null) {
+        initialHeight = window.innerHeight;
+        console.log(`[ViewportFix] 📐 visualViewport.resize: initialHeight未初始化,记录初始高度, window.innerHeight=${window.innerHeight}px, currentHeight=${currentHeight}px`);
+        // 如果当前高度明显小于 window.innerHeight,说明键盘可能已经弹出
+        // 此时应该使用 window.innerHeight 作为基准,并立即锁定
+        if (currentHeight < window.innerHeight - 50) {
+          console.log(`[ViewportFix] 📐 visualViewport.resize: 检测到键盘已弹出(初始化时), 立即锁定`);
+          forceLockViewportHeight('resize-init-keyboard-detected');
+          return;
+        }
+        // 否则正常设置高度
+        setViewportHeight(false, true);
+        lastHeight = currentHeight;
+        return;
+      }
       
       // 检查当前活跃元素是否为输入框(即使 focusin 还没触发)
-      const activeElement = document.activeElement as HTMLElement;
-      const isActiveInput = isInputElement(activeElement);
+      const heightDiff = initialHeight - currentHeight;
+      
+      console.log(`[ViewportFix] 📐 visualViewport.resize: currentHeight=${currentHeight}px, initialHeight=${initialHeight}px, heightDiff=${heightDiff}px, isInputFocused=${isInputFocused}, isActiveInput=${isActiveInput}, activeElement=${activeElement?.tagName}, isLocked=${isLocked}`);
       
       // 如果输入框处于焦点状态,或当前活跃元素是输入框,强制锁定
       if (isInputFocused || isActiveInput) {
-        if (initialHeight === null) {
-          initialHeight = window.innerHeight;
-        }
-        forceLockViewportHeight();
+        console.log(`[ViewportFix] 📐 visualViewport.resize: 检测到输入框焦点,立即锁定`);
+        forceLockViewportHeight('resize-input-focused');
         return;
       }
       
       // 检测键盘是否弹出(高度突然大幅减小)
-      if (initialHeight !== null) {
-        const heightDiff = initialHeight - currentHeight;
-        // 如果高度减少超过 100px,认为是键盘弹出,立即锁定
-        if (heightDiff > 100) {
-          forceLockViewportHeight();
+      // 降低阈值到 50px,更早检测到键盘弹出(特别是 inputMode="numeric" 等快速键盘)
+      
+      // 如果高度减小超过 50px,立即锁定(键盘弹出)
+      if (heightDiff > 50) {
+        console.log(`[ViewportFix] 📐 visualViewport.resize: 检测到键盘弹出(heightDiff>50), 立即锁定`);
+        forceLockViewportHeight('resize-height-diff-50');
+        return;
+      }
+      
+      // 如果高度减小超过 20px 且当前高度明显小于初始高度,也可能是键盘弹出(更早检测)
+      // 这对于 inputMode="numeric" 等快速键盘特别重要
+      if (heightDiff > 20 && currentHeight < initialHeight * 0.9) {
+        console.log(`[ViewportFix] 📐 visualViewport.resize: 检测到键盘弹出(heightDiff>20且<90%), 立即锁定`);
+        forceLockViewportHeight('resize-height-diff-20-90');
+        return;
+      }
+      
+      // 检测高度快速下降趋势(即使单次变化不大,但连续下降也可能表示键盘弹出)
+      if (lastHeight !== null) {
+        const rapidHeightDrop = lastHeight - currentHeight;
+        // 如果高度在短时间内快速下降超过 30px,可能是键盘弹出
+        if (rapidHeightDrop > 30 && currentHeight < initialHeight - 30) {
+          console.log(`[ViewportFix] 📐 visualViewport.resize: 检测到快速下降趋势(rapidDrop>30), 立即锁定, lastHeight=${lastHeight}px, rapidDrop=${rapidHeightDrop}px`);
+          forceLockViewportHeight('resize-rapid-drop');
           return;
         }
       }
       
+      // 更新记录的高度
+      lastHeight = currentHeight;
+      
       // 其他情况正常更新(地址栏显示/隐藏等)
+      console.log(`[ViewportFix] 📐 visualViewport.resize: 正常更新高度, currentHeight=${currentHeight}px`);
       setViewportHeight();
     });
     

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

@@ -713,7 +713,7 @@ export const BeginPage = () => {
               style={{
                 position: 'absolute',
                 top: px2vmin(185),
-                right: px2vmin(54),
+                left: px2vmin(-37),
                 width: px2vmin(423),
                 height: px2vmin(423),
                 objectFit: 'contain',
@@ -731,7 +731,7 @@ export const BeginPage = () => {
                 top: px2vmin(138),
                 left: '50%',
                 transform: `translateX(calc(-50% - ${px2vmin(54)})) translateZ(0)`,
-                width: px2vmin(245),
+                width: '100%',
                 height: px2vmin(113),
                 background: 'linear-gradient(97.3deg, rgba(245, 243, 240, 0.8) 2.21%, rgba(245, 243, 240, 0.3) 150.89%)',
                 WebkitBackgroundClip: 'text',

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

@@ -309,6 +309,7 @@ export const FirstLoginPage = () => {
                 <input
                   ref={phoneRef}
                   type="text"
+                  inputMode="numeric"
                   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)]"
                   style={{
                     fontSize: px2vmin(16),

+ 1 - 0
frontend/src/pages/LoginPage.tsx

@@ -353,6 +353,7 @@ export const LoginPage = () => {
               >
                 <input
                   type="text"
+                  inputMode="numeric"
                   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)]"
                   style={{
                     fontSize: px2vmin(16),

+ 1 - 0
frontend/src/pages/LoginVerificationCodePage.tsx

@@ -314,6 +314,7 @@ export const LoginVerificationCodePage = () => {
               >
                 <input
                   type="text"
+                  inputMode="numeric"
                   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)]"
                   style={{
                     fontSize: px2vmin(16),

+ 57 - 2
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -53,9 +53,64 @@ export const LoginVerificationInputPage = () => {
   }, []);
 
   // 自动聚焦到第一个输入框
+  // 延迟聚焦,确保视口高度修复机制已初始化完成,防止键盘弹出时界面缩小
   useEffect(() => {
-    if (codeRefs[0].current) {
-      codeRefs[0].current.focus();
+    // 检查 --vh CSS 变量是否已初始化(视口高度修复机制已就绪)
+    const checkViewportReady = (): boolean => {
+      const vhValue = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+      const isReady = vhValue !== '' && vhValue !== null;
+      console.log(`[LoginVerificationInputPage] 🔍 检查视口就绪: --vh=${vhValue}, isReady=${isReady}`);
+      return isReady;
+    };
+
+    // 尝试聚焦的函数
+    const attemptFocus = (source: string) => {
+      if (codeRefs[0].current) {
+        const input = codeRefs[0].current;
+        const vhValue = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+        const initialHeight = window.innerHeight;
+        const currentHeight = window.visualViewport?.height || window.innerHeight;
+        console.log(`[LoginVerificationInputPage] 🎯 尝试聚焦: source=${source}, --vh=${vhValue}, initialHeight=${initialHeight}px, currentHeight=${currentHeight}px, inputMode=${input.inputMode}`);
+        input.focus();
+        // 聚焦后立即检查状态
+        setTimeout(() => {
+          const afterFocusVh = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+          const afterFocusHeight = window.visualViewport?.height || window.innerHeight;
+          console.log(`[LoginVerificationInputPage] ✅ 聚焦后状态: --vh=${afterFocusVh}, currentHeight=${afterFocusHeight}px, document.activeElement=${document.activeElement?.tagName}`);
+        }, 100);
+      } else {
+        console.log(`[LoginVerificationInputPage] ⚠️ 聚焦失败: codeRefs[0].current 为 null`);
+      }
+    };
+
+    // 如果视口已就绪,延迟一小段时间后聚焦(确保初始化完成)
+    if (checkViewportReady()) {
+      console.log(`[LoginVerificationInputPage] ✅ 视口已就绪,延迟150ms后聚焦`);
+      // 使用 requestAnimationFrame 确保在下一帧执行,给初始化更多时间
+      requestAnimationFrame(() => {
+        setTimeout(() => attemptFocus('ready-delayed'), 150);
+      });
+    } else {
+      console.log(`[LoginVerificationInputPage] ⏳ 视口未就绪,等待初始化...`);
+      // 如果还未就绪,等待一段时间后重试
+      let checkCount = 0;
+      const checkInterval = setInterval(() => {
+        checkCount++;
+        if (checkViewportReady()) {
+          console.log(`[LoginVerificationInputPage] ✅ 视口已就绪(重试${checkCount}次),延迟150ms后聚焦`);
+          clearInterval(checkInterval);
+          requestAnimationFrame(() => {
+            setTimeout(() => attemptFocus('retry-delayed'), 150);
+          });
+        }
+      }, 50);
+
+      // 最多等待 500ms,超时后强制聚焦
+      setTimeout(() => {
+        clearInterval(checkInterval);
+        console.log(`[LoginVerificationInputPage] ⏰ 等待超时,强制聚焦`);
+        attemptFocus('timeout');
+      }, 500);
     }
   }, []);
 

+ 1 - 0
frontend/src/pages/VerificationCodePage.tsx

@@ -316,6 +316,7 @@ export const VerificationCodePage = () => {
                 <input
                   ref={phoneRef}
                   type="text"
+                  inputMode="numeric"
                   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)]"
                   style={{
                     fontSize: px2vmin(16),

+ 57 - 2
frontend/src/pages/VerificationInputPage.tsx

@@ -56,9 +56,64 @@ export const VerificationInputPage = () => {
   }, []);
 
   // 自动聚焦到第一个输入框
+  // 延迟聚焦,确保视口高度修复机制已初始化完成,防止键盘弹出时界面缩小
   useEffect(() => {
-    if (codeRefs[0].current) {
-      codeRefs[0].current.focus();
+    // 检查 --vh CSS 变量是否已初始化(视口高度修复机制已就绪)
+    const checkViewportReady = (): boolean => {
+      const vhValue = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+      const isReady = vhValue !== '' && vhValue !== null;
+      console.log(`[VerificationInputPage] 🔍 检查视口就绪: --vh=${vhValue}, isReady=${isReady}`);
+      return isReady;
+    };
+
+    // 尝试聚焦的函数
+    const attemptFocus = (source: string) => {
+      if (codeRefs[0].current) {
+        const input = codeRefs[0].current;
+        const vhValue = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+        const initialHeight = window.innerHeight;
+        const currentHeight = window.visualViewport?.height || window.innerHeight;
+        console.log(`[VerificationInputPage] 🎯 尝试聚焦: source=${source}, --vh=${vhValue}, initialHeight=${initialHeight}px, currentHeight=${currentHeight}px, inputMode=${input.inputMode}`);
+        input.focus();
+        // 聚焦后立即检查状态
+        setTimeout(() => {
+          const afterFocusVh = getComputedStyle(document.documentElement).getPropertyValue('--vh');
+          const afterFocusHeight = window.visualViewport?.height || window.innerHeight;
+          console.log(`[VerificationInputPage] ✅ 聚焦后状态: --vh=${afterFocusVh}, currentHeight=${afterFocusHeight}px, document.activeElement=${document.activeElement?.tagName}`);
+        }, 100);
+      } else {
+        console.log(`[VerificationInputPage] ⚠️ 聚焦失败: codeRefs[0].current 为 null`);
+      }
+    };
+
+    // 如果视口已就绪,延迟一小段时间后聚焦(确保初始化完成)
+    if (checkViewportReady()) {
+      console.log(`[VerificationInputPage] ✅ 视口已就绪,延迟150ms后聚焦`);
+      // 使用 requestAnimationFrame 确保在下一帧执行,给初始化更多时间
+      requestAnimationFrame(() => {
+        setTimeout(() => attemptFocus('ready-delayed'), 150);
+      });
+    } else {
+      console.log(`[VerificationInputPage] ⏳ 视口未就绪,等待初始化...`);
+      // 如果还未就绪,等待一段时间后重试
+      let checkCount = 0;
+      const checkInterval = setInterval(() => {
+        checkCount++;
+        if (checkViewportReady()) {
+          console.log(`[VerificationInputPage] ✅ 视口已就绪(重试${checkCount}次),延迟150ms后聚焦`);
+          clearInterval(checkInterval);
+          requestAnimationFrame(() => {
+            setTimeout(() => attemptFocus('retry-delayed'), 150);
+          });
+        }
+      }, 50);
+
+      // 最多等待 500ms,超时后强制聚焦
+      setTimeout(() => {
+        clearInterval(checkInterval);
+        console.log(`[VerificationInputPage] ⏰ 等待超时,强制聚焦`);
+        attemptFocus('timeout');
+      }, 500);
     }
   }, []);
 

+ 1 - 1
frontend/src/pages/WelcomePage.tsx

@@ -768,7 +768,7 @@ export const WelcomePage = () => {
                 position: 'absolute',
                 top: px2vmin(138),
                 left: px2vmin(45),
-                width: px2vmin(245),
+                width: '100%',
                 height: px2vmin(113),
                 background: 'linear-gradient(97.3deg, rgba(245, 243, 240, 0.8) 2.21%, rgba(245, 243, 240, 0.3) 150.89%)',
                 WebkitBackgroundClip: 'text',

+ 416 - 0
项目文档/PWA添加到主屏幕说明.md

@@ -0,0 +1,416 @@
+# PWA "添加到主屏幕" 详细说明
+
+## 一、核心概念
+
+### 1.1 什么是"添加到主屏幕"?
+
+**"添加到主屏幕"** 是指用户将网页应用安装到手机主屏幕,创建一个类似原生 App 的图标,点击后以**独立窗口**模式运行(隐藏浏览器 UI)。
+
+### 1.2 PWA 的两种访问方式
+
+| 访问方式 | 显示效果 | 浏览器UI | 可视高度 |
+|---------|---------|---------|---------|
+| **普通浏览器访问** | 在浏览器中打开 | ✅ 显示地址栏/工具栏 | ~667px(变小) |
+| **添加到主屏幕后访问** | 独立窗口(类似App) | ❌ 完全隐藏 | 812px(完整) |
+
+---
+
+## 二、为什么需要手动操作?
+
+### 2.1 浏览器安全策略
+
+浏览器**不允许网页自动安装**到主屏幕,必须由**用户主动操作**,这是为了防止恶意网站强制安装应用。
+
+### 2.2 不同平台的行为
+
+#### iOS Safari
+- ❌ **不会自动提示**安装
+- ✅ 需要用户**手动操作**:分享 → 添加到主屏幕
+- ✅ 安装后以独立窗口运行(隐藏浏览器UI)
+
+#### Android Chrome
+- ✅ **会自动显示安装横幅**(满足条件时)
+- ✅ 也可以手动:菜单 → 安装应用
+- ✅ 安装后以独立窗口运行
+
+---
+
+## 三、具体操作步骤
+
+### 3.1 iOS Safari 操作步骤
+
+**步骤 1**: 在 Safari 中打开网页
+
+**步骤 2**: 点击底部**分享按钮**(方框+向上箭头图标)
+
+**步骤 3**: 在分享菜单中找到**"添加到主屏幕"**选项
+
+**步骤 4**: 点击后可以编辑应用名称和图标
+
+**步骤 5**: 点击**"添加"**按钮
+
+**步骤 6**: 主屏幕会出现应用图标,点击后以独立窗口打开
+
+**图示流程**:
+```
+Safari 打开网页
+    ↓
+点击分享按钮(底部)
+    ↓
+选择"添加到主屏幕"
+    ↓
+编辑名称/图标(可选)
+    ↓
+点击"添加"
+    ↓
+主屏幕出现图标 ✅
+```
+
+### 3.2 Android Chrome 操作步骤
+
+**方式一:自动提示(推荐)**
+- 如果满足 PWA 条件,Chrome 会自动显示**"安装应用"**横幅
+- 用户点击横幅即可安装
+
+**方式二:手动安装**
+1. 点击右上角**三点菜单**
+2. 选择**"安装应用"**或**"添加到主屏幕"**
+3. 确认安装
+4. 主屏幕出现图标
+
+---
+
+## 四、用户体验对比
+
+### 4.1 普通浏览器访问
+
+```
+┌─────────────────────────┐
+│  ← 地址栏 (占用空间)      │  ← 浏览器UI
+├─────────────────────────┤
+│                         │
+│   网页内容区域           │  ← 实际可视高度 ~667px
+│   (元素变小 18%)        │
+│                         │
+├─────────────────────────┤
+│  ← 工具栏 (占用空间)     │  ← 浏览器UI
+└─────────────────────────┘
+```
+
+**问题**:
+- ❌ 地址栏和工具栏占用空间
+- ❌ 实际可视高度只有 ~667px(而非 812px)
+- ❌ 元素显示变小
+
+### 4.2 添加到主屏幕后访问
+
+```
+┌─────────────────────────┐
+│                         │
+│   网页内容区域           │  ← 完整高度 812px
+│   (元素正常大小)        │
+│                         │
+│                         │
+└─────────────────────────┘
+```
+
+**优势**:
+- ✅ 完全隐藏浏览器UI
+- ✅ 获得完整 812px 高度
+- ✅ 元素正常显示
+- ✅ 体验类似原生 App
+
+---
+
+## 五、技术实现
+
+### 5.1 检测是否已安装
+
+**检测 PWA 模式**:
+```typescript
+/**
+ * 检测是否以 PWA 模式运行(已添加到主屏幕)
+ */
+export const isPWAMode = (): boolean => {
+  // 方法 1: iOS Safari
+  if ((window.navigator as any).standalone === true) {
+    return true;
+  }
+  
+  // 方法 2: Android Chrome / 其他浏览器
+  if (window.matchMedia('(display-mode: standalone)').matches) {
+    return true;
+  }
+  
+  // 方法 3: 检测是否在浏览器中
+  // 如果 window.navigator.standalone 为 undefined,且不在浏览器中,可能是 PWA
+  if (window.matchMedia('(display-mode: standalone)').matches) {
+    return true;
+  }
+  
+  return false;
+};
+```
+
+### 5.2 引导用户安装
+
+**显示安装提示**:
+```typescript
+import { isPWAMode } from '@/utils/pwa';
+
+const InstallPrompt = () => {
+  const [showPrompt, setShowPrompt] = useState(false);
+  
+  useEffect(() => {
+    // 如果未安装,显示提示
+    if (!isPWAMode()) {
+      // 检查是否已显示过提示(避免重复打扰)
+      const hasShown = localStorage.getItem('pwa-prompt-shown');
+      if (!hasShown) {
+        setShowPrompt(true);
+      }
+    }
+  }, []);
+  
+  if (!showPrompt) return null;
+  
+  return (
+    <div className="pwa-install-prompt">
+      <h3>获得更好体验</h3>
+      <p>添加到主屏幕,享受全屏体验</p>
+      <div className="install-steps">
+        <p>iOS: 分享 → 添加到主屏幕</p>
+        <p>Android: 点击菜单 → 安装应用</p>
+      </div>
+      <button onClick={() => {
+        setShowPrompt(false);
+        localStorage.setItem('pwa-prompt-shown', 'true');
+      }}>
+        知道了
+      </button>
+    </div>
+  );
+};
+```
+
+### 5.3 Android Chrome 自动安装提示
+
+**监听安装提示事件**:
+```typescript
+let deferredPrompt: any = null;
+
+// 监听 beforeinstallprompt 事件(Android Chrome)
+window.addEventListener('beforeinstallprompt', (e) => {
+  // 阻止默认的安装提示
+  e.preventDefault();
+  // 保存事件,稍后可以手动触发
+  deferredPrompt = e;
+  
+  // 显示自定义安装按钮
+  showInstallButton();
+});
+
+// 用户点击安装按钮时
+const handleInstallClick = async () => {
+  if (!deferredPrompt) return;
+  
+  // 显示安装提示
+  deferredPrompt.prompt();
+  
+  // 等待用户选择
+  const { outcome } = await deferredPrompt.userChoice;
+  
+  if (outcome === 'accepted') {
+    console.log('用户同意安装');
+  } else {
+    console.log('用户拒绝安装');
+  }
+  
+  // 清除保存的事件
+  deferredPrompt = null;
+};
+```
+
+---
+
+## 六、实际场景示例
+
+### 6.1 场景一:用户首次访问
+
+**用户操作流程**:
+1. 用户在 Safari 中打开 `https://lyrria.com`
+2. 看到网页内容(但元素较小,因为浏览器UI占用空间)
+3. 看到"添加到主屏幕"提示
+4. 按照提示操作:分享 → 添加到主屏幕
+5. 主屏幕出现 Lumi 图标
+6. 点击图标,以独立窗口打开(全屏,元素正常大小)
+
+### 6.2 场景二:用户已安装
+
+**用户操作流程**:
+1. 用户直接点击主屏幕的 Lumi 图标
+2. 应用以独立窗口打开(无浏览器UI)
+3. 获得完整 812px 高度,元素正常显示
+
+### 6.3 场景三:用户未安装,继续使用浏览器
+
+**用户操作流程**:
+1. 用户忽略安装提示,继续在浏览器中使用
+2. 元素仍然较小(因为浏览器UI占用空间)
+3. 可以正常使用,但体验不如 PWA 模式
+
+---
+
+## 七、常见问题
+
+### Q1: 为什么不能自动安装?
+
+**A**: 浏览器安全策略不允许网页自动安装,必须由用户主动操作。这是为了防止恶意网站强制安装应用。
+
+### Q2: iOS 为什么不自动提示?
+
+**A**: iOS Safari 的设计理念是**不打扰用户**,所以不会自动显示安装提示。需要用户主动操作。
+
+### Q3: 用户不安装会怎样?
+
+**A**: 用户仍然可以在浏览器中正常使用,但:
+- 元素会变小(因为浏览器UI占用空间)
+- 无法获得完整 812px 高度
+- 体验不如 PWA 模式
+
+### Q4: 如何提高安装率?
+
+**A**: 
+1. **显示友好的安装提示**(说明好处)
+2. **提供清晰的操作步骤**(图文说明)
+3. **在合适的时机提示**(如首次访问、使用一段时间后)
+4. **Android 自动提示**(满足条件时 Chrome 会自动显示)
+
+### Q5: 微信内置浏览器支持吗?
+
+**A**: ❌ **不支持**。微信内置浏览器不支持 PWA,也无法添加到主屏幕。只能使用普通浏览器访问。
+
+---
+
+## 八、总结
+
+### 8.1 核心要点
+
+1. **"添加到主屏幕"** = 将网页安装为独立应用
+2. **必须用户手动操作**,不能自动安装
+3. **安装后**以独立窗口运行,隐藏浏览器UI
+4. **获得完整高度**,元素正常显示
+
+### 8.2 实施建议
+
+1. ✅ **创建 manifest.json** 配置 PWA
+2. ✅ **检测是否已安装**,显示相应提示
+3. ✅ **引导用户安装**(iOS 手动,Android 自动)
+4. ✅ **说明安装好处**(全屏体验、元素正常大小)
+
+### 8.3 用户体验优化
+
+- 显示友好的安装提示(不要强制)
+- 提供清晰的操作步骤(图文说明)
+- 安装后自动隐藏提示
+- 记住用户选择(避免重复打扰)
+
+---
+
+## 九、代码示例
+
+### 9.1 完整的 PWA 检测和引导组件
+
+```typescript
+// src/components/PWAInstallPrompt.tsx
+import { useState, useEffect } from 'react';
+import { isPWAMode } from '@/utils/pwa';
+
+export const PWAInstallPrompt = () => {
+  const [showPrompt, setShowPrompt] = useState(false);
+  const [isIOS, setIsIOS] = useState(false);
+  const [isAndroid, setIsAndroid] = useState(false);
+  
+  useEffect(() => {
+    // 检测设备类型
+    const ua = navigator.userAgent;
+    setIsIOS(/iphone|ipad|ipod/i.test(ua));
+    setIsAndroid(/android/i.test(ua));
+    
+    // 如果未安装,且未显示过提示,则显示
+    if (!isPWAMode()) {
+      const hasShown = localStorage.getItem('pwa-prompt-shown');
+      if (!hasShown) {
+        // 延迟 3 秒显示,避免打扰用户
+        setTimeout(() => {
+          setShowPrompt(true);
+        }, 3000);
+      }
+    }
+  }, []);
+  
+  if (!showPrompt || isPWAMode()) return null;
+  
+  return (
+    <div className="pwa-prompt-overlay">
+      <div className="pwa-prompt-card">
+        <h3>获得更好体验</h3>
+        <p>添加到主屏幕,享受全屏体验</p>
+        
+        {isIOS && (
+          <div className="install-steps">
+            <p>1. 点击底部分享按钮</p>
+            <p>2. 选择"添加到主屏幕"</p>
+            <p>3. 点击"添加"</p>
+          </div>
+        )}
+        
+        {isAndroid && (
+          <div className="install-steps">
+            <p>点击浏览器菜单 → 安装应用</p>
+          </div>
+        )}
+        
+        <button onClick={() => {
+          setShowPrompt(false);
+          localStorage.setItem('pwa-prompt-shown', 'true');
+        }}>
+          知道了
+        </button>
+      </div>
+    </div>
+  );
+};
+```
+
+### 9.2 PWA 工具函数
+
+```typescript
+// src/utils/pwa.ts
+/**
+ * 检测是否以 PWA 模式运行(已添加到主屏幕)
+ */
+export const isPWAMode = (): boolean => {
+  // iOS Safari
+  if ((window.navigator as any).standalone === true) {
+    return true;
+  }
+  
+  // Android Chrome / 其他浏览器
+  if (window.matchMedia('(display-mode: standalone)').matches) {
+    return true;
+  }
+  
+  return false;
+};
+
+/**
+ * 检测是否支持 PWA 安装
+ */
+export const isPWAInstallable = (): boolean => {
+  // Android Chrome 支持 beforeinstallprompt
+  // iOS Safari 不支持,需要手动操作
+  return 'serviceWorker' in navigator;
+};
+```
+

+ 260 - 0
项目文档/前端界面自适应方案分析.md

@@ -0,0 +1,260 @@
+# 前端界面自适应方案分析
+
+## 一、核心适配方案
+
+### 1.1 主要技术栈
+- **PostCSS 插件**: `postcss-px-to-viewport` (v1.1.1)
+- **设计稿基准**: 375px × 812px (iPhone X/11/12/13/14)
+- **转换单位**: vw (viewport width)
+- **转换精度**: 5 位小数
+
+### 1.2 适配策略
+采用**双轨制适配方案**:
+- **CSS 文件**: 自动转换(PostCSS 插件)
+- **内联样式**: 手动转换(工具函数)
+
+---
+
+## 二、PostCSS 配置详解
+
+### 2.1 核心配置 (`postcss.config.js`)
+```javascript
+'postcss-px-to-viewport': {
+  viewportWidth: 375,        // 设计稿宽度
+  viewportHeight: 812,       // 设计稿高度
+  unitPrecision: 5,          // 转换精度
+  viewportUnit: 'vw',        // 转换单位
+  minPixelValue: 1,         // 最小转换值(1px 边框保留)
+  mediaQuery: false,         // 媒体查询不转换
+  exclude: /node_modules/i,  // 排除第三方库
+}
+```
+
+### 2.2 选择器黑名单
+以下选择器/类名**不转换**(保持 px):
+- `html` / `body` - 根元素
+- `#root` - 根容器(保证 max-width 固定)
+- `.content-card` - 主内容卡片(保持 600px 封顶)
+- `.ignore` - 手动标记的不转换类
+
+---
+
+## 三、工具函数体系 (`src/utils/viewport.ts`)
+
+### 3.1 单位转换函数
+
+| 函数 | 用途 | 转换规则 | 示例 |
+|------|------|----------|------|
+| `px2vw(px)` | 横向属性 | `calc(px * min(100vw, 600PX) / 375)` | `width`, `left`, `right`, `padding-left/right` |
+| `px2vh(px)` | 纵向属性 | `calc((px/812) * 100 * var(--vh, 1vh))` | `height`, `top`, `bottom`, `padding-top/bottom` |
+| `px2vmin(px)` | 双向最小 | `min(px2vw(px), px2vh(px))` | 确保不因单一维度过度放大 |
+| `px2vmax(px)` | 双向最大 | `max(px2vw(px), px2vh(px))` | 确保填满容器 |
+| `px2fluidFontSize(px)` | 流体字体 | `clamp(min, min(vw, vh), max)` | 字体大小(防止过度放大) |
+
+### 3.2 关键特性
+
+#### 宽度封顶机制
+- **规则**: 视口宽度 > 600px 时,按 600px 计算
+- **实现**: `min(100vw, 600PX)` (大写 PX 避免转换)
+- **效果**: 大屏设备不再继续放大,保持合理尺寸
+
+#### 高度修复机制
+- **问题**: 移动端地址栏/工具栏导致 `vh` 不准确
+- **解决**: JS 动态设置 CSS 变量 `--vh`
+- **实现**: `var(--vh, 1vh)` 降级方案
+
+---
+
+## 四、视口高度修复 (`src/main.tsx`)
+
+### 4.1 核心逻辑
+```typescript
+// 设置 CSS 变量 --vh
+document.documentElement.style.setProperty('--vh', `${vh}px`);
+```
+
+### 4.2 修复场景
+
+| 场景 | 处理策略 | 触发时机 |
+|------|----------|----------|
+| 键盘弹出 | 锁定高度(不更新) | `touchstart` / `focusin` |
+| 键盘收起 | 延迟 300ms 恢复 | `focusout` + 定时器 |
+| 地址栏显示/隐藏 | 正常更新 | `visualViewport.resize` |
+| 横竖屏切换 | 重置并强制更新 | `orientationchange` |
+
+### 4.3 锁定机制
+- **输入框聚焦**: 立即锁定,防止键盘弹出时界面缩小
+- **高度检测**: 高度减少 > 100px 时判定为键盘弹出
+- **防抖处理**: 150ms 防抖,避免频繁更新
+
+---
+
+## 五、Tailwind CSS 配置
+
+### 5.1 响应式断点
+```javascript
+screens: {
+  'xs': '320px',   // 超小屏(旧手机)
+  'sm': '640px',   // 小屏(手机横屏/小平板)
+  'md': '768px',   // 中屏(平板竖屏)
+  'lg': '1024px',  // 大屏(平板横屏/笔记本)
+  'xl': '1280px',  // 超大屏(桌面)
+  '2xl': '1536px', // 2K屏
+}
+```
+
+### 5.2 自定义单位
+- **间距**: `2vw` ~ `15vw`, `8vh` ~ `90vh`
+- **字体**: `fluid-xs` ~ `fluid-4xl` (使用 `clamp + min(vw, vh)`)
+- **尺寸**: `responsive-sm/md/lg/xl` (使用 `clamp`)
+
+### 5.3 流体字体示例
+```css
+font-size: clamp(11.2px, min(3.73vw, 1.72vh), 16.8px);
+/* 最小值 11.2px,最大值 16.8px,中间值取 min(vw, vh) */
+```
+
+---
+
+## 六、全局样式策略 (`src/index.css`)
+
+### 6.1 容器限制
+```css
+#root {
+  width: min(100%, 600PX);    /* 最大宽度 600px */
+  max-width: 600PX;            /* 大写 PX 避免转换 */
+  margin: 0 auto;              /* 居中显示 */
+}
+```
+
+### 6.2 响应式工具类
+- `.text-responsive-*`: 流体字体(`clamp + min(vw, vh)`)
+- `.spacing-responsive-*`: 流体间距(`clamp`)
+- `.btn-responsive`: 响应式按钮(`clamp(44px, 12vw, 96px)`)
+
+### 6.3 iOS 安全区适配
+```css
+.safe-top {
+  padding-top: env(safe-area-inset-top);
+}
+.safe-bottom {
+  padding-bottom: env(safe-area-inset-bottom);
+}
+```
+
+---
+
+## 七、Viewport Meta 配置
+
+### 7.1 HTML 配置 (`index.html`)
+```html
+<meta name="viewport" content="
+  width=device-width,
+  initial-scale=1.0,
+  maximum-scale=1.0,
+  user-scalable=no,
+  viewport-fit=cover
+">
+```
+
+### 7.2 关键参数
+- `viewport-fit=cover`: 支持 iPhone X+ 刘海屏
+- `user-scalable=no`: 禁止缩放(保持设计一致性)
+- `maximum-scale=1.0`: 防止 iOS Safari 自动缩放
+
+---
+
+## 八、适配规则总结
+
+### 8.1 单位选择规则
+
+| 属性类型 | 使用单位 | 工具函数 | 说明 |
+|---------|---------|---------|------|
+| 横向属性 | vw | `px2vw()` | `width`, `left`, `right`, `padding-left/right`, `margin-left/right` |
+| 纵向属性 | vh | `px2vh()` | `height`, `top`, `bottom`, `padding-top/bottom`, `margin-top/bottom` |
+| 字体大小 | fluid | `px2fluidFontSize()` | 使用 `clamp + min(vw, vh)` 防止过度放大 |
+| 双向属性 | vmin/vmax | `px2vmin()` / `px2vmax()` | 需要同时考虑宽高的场景 |
+
+### 8.2 使用场景
+
+| 场景 | CSS 文件 | 内联样式 | 说明 |
+|-----|----------|---------|------|
+| 静态样式 | ✅ 直接写 px | ❌ | PostCSS 自动转换 |
+| 动态计算 | ❌ | ✅ 使用工具函数 | 如 `width: px2vw(100)` |
+| 固定尺寸 | ✅ 使用 `.ignore` | ✅ 直接写 px | 如边框、图标 |
+| 最大宽度 | ✅ 使用 `600PX` | ✅ 使用 `600PX` | 大写 PX 避免转换 |
+
+---
+
+## 九、特殊处理
+
+### 9.1 1px 边框问题
+- **配置**: `minPixelValue: 1` (1px 不转换)
+- **问题**: 某些设备上 1px 可能显示过粗
+- **解决**: 使用大写 `PX` 或 `.ignore` 类保持 1px
+
+### 9.2 媒体查询
+- **配置**: `mediaQuery: false` (媒体查询中的 px 不转换)
+- **原因**: 媒体查询需要固定 px 值,不应转换为 vw
+
+### 9.3 第三方库
+- **配置**: `exclude: /node_modules/i`
+- **原因**: 第三方组件使用自己的适配方案,不应转换
+
+---
+
+## 十、性能优化
+
+### 10.1 CSS 变量缓存
+- `--vh` 变量在 JS 中计算并缓存
+- 防抖处理(150ms)减少更新频率
+
+### 10.2 锁定机制
+- 输入框聚焦时锁定高度,避免频繁计算
+- 键盘收起后延迟 300ms 恢复,等待动画完成
+
+### 10.3 响应式图片
+```css
+img {
+  max-width: 100%;
+  height: auto;
+}
+```
+
+---
+
+## 十一、兼容性说明
+
+### 11.1 浏览器支持
+- **iOS Safari**: 8+ (支持 `env()` 安全区)
+- **Android Chrome**: 4.4+ (支持 `visualViewport`)
+- **现代浏览器**: 全面支持 vw/vh/clamp
+
+### 11.2 降级方案
+- `var(--vh, 1vh)`: 未初始化时降级为原生 vh
+- `@supports`: 使用特性检测提供降级样式
+
+---
+
+## 十二、总结
+
+### 12.1 方案特点
+1. **自动化**: PostCSS 插件自动转换 CSS 文件中的 px
+2. **灵活性**: 工具函数支持动态计算和内联样式
+3. **精确性**: JS 修复移动端 vh 问题,确保高度准确
+4. **封顶机制**: 600px 最大宽度,防止大屏过度放大
+5. **流体字体**: 使用 `clamp + min(vw, vh)` 防止字体过度放大
+
+### 12.2 适用场景
+- ✅ 移动端 H5 应用
+- ✅ 响应式 Web 应用
+- ✅ 需要精确适配多尺寸设备的场景
+- ❌ 桌面端优先的应用(建议使用 rem 或固定布局)
+
+### 12.3 最佳实践
+1. **CSS 文件**: 直接写 px,让 PostCSS 自动转换
+2. **内联样式**: 使用工具函数 `px2vw()` / `px2vh()`
+3. **固定尺寸**: 使用大写 `PX` 或 `.ignore` 类
+4. **字体大小**: 优先使用 `px2fluidFontSize()` 或 Tailwind `fluid-*` 类
+5. **容器宽度**: 使用 `min(100%, 600PX)` 实现封顶
+

+ 462 - 0
项目文档/移动端浏览器可视区域问题解决方案分析.md

@@ -0,0 +1,462 @@
+# 移动端浏览器可视区域问题解决方案分析
+
+## 一、问题描述
+
+### 1.1 核心问题
+- **设计稿尺寸**: 375px × 812px(全屏高度)
+- **实际可视区域**: 手机浏览器有地址栏/工具栏,实际可视高度 < 812px
+- **影响**: 按 812px 设计的高度元素显示变小,布局比例失调
+
+### 1.2 典型场景
+| 设备 | 屏幕高度 | 浏览器UI占用 | 实际可视高度 | 损失比例 |
+|------|---------|-------------|-------------|---------|
+| iPhone 14 | 844px | ~177px | ~667px | -18% |
+| iPhone 13 | 812px | ~145px | ~667px | -18% |
+| Android 1080p | 1920px | ~144px | ~1776px | -7.5% |
+
+**注**: 地址栏/工具栏会随滚动显示/隐藏,导致可视高度动态变化
+
+---
+
+## 二、可行方案分析
+
+### 方案一:PWA 全屏模式(推荐 ⭐⭐⭐⭐⭐)
+
+#### 2.1 实现原理
+通过 `manifest.json` 配置,让应用以**独立窗口**或**全屏**模式运行,隐藏浏览器 UI。
+
+#### 2.2 配置方式
+
+**创建 `public/manifest.json`**:
+```json
+{
+  "name": "Lumi",
+  "short_name": "Lumi",
+  "description": "Your adventure begins now.",
+  "start_url": "/",
+  "display": "standalone",  // 或 "fullscreen"
+  "background_color": "#E8E8E8",
+  "theme_color": "#667eea",
+  "orientation": "portrait",
+  "icons": [
+    {
+      "src": "/Lumi-cap.ico",
+      "sizes": "192x192",
+      "type": "image/x-icon"
+    }
+  ]
+}
+```
+
+**在 `index.html` 中引入**:
+```html
+<link rel="manifest" href="/manifest.json">
+```
+
+#### 2.3 Display 模式对比
+
+| 模式 | 说明 | 浏览器UI | 适用场景 |
+|------|------|---------|---------|
+| `standalone` | 独立窗口(类似原生App) | 完全隐藏 | ✅ **推荐** |
+| `fullscreen` | 全屏模式 | 完全隐藏 | 游戏/视频应用 |
+| `minimal-ui` | 最小UI | 部分隐藏 | 部分浏览器不支持 |
+| `browser` | 浏览器模式 | 完全显示 | 默认模式 |
+
+#### 2.4 优势
+- ✅ **完全隐藏浏览器UI**,获得完整 812px 高度
+- ✅ **用户体验好**,类似原生 App
+- ✅ **支持添加到主屏幕**,用户可安装
+- ✅ **iOS Safari 支持**(iOS 11.3+)
+- ✅ **Android Chrome 支持**(Android 4.4+)
+
+#### 2.5 限制
+- ⚠️ **需要用户手动添加**到主屏幕(首次访问)
+- ⚠️ **普通浏览器访问**仍显示浏览器UI
+- ⚠️ **部分浏览器不支持**(如微信内置浏览器)
+
+#### 2.6 兼容性
+| 平台 | 浏览器 | 支持情况 | 备注 |
+|------|--------|---------|------|
+| iOS | Safari | ✅ 11.3+ | 需要添加到主屏幕 |
+| Android | Chrome | ✅ 4.4+ | 支持安装 |
+| Android | 微信内置 | ❌ | 不支持 manifest |
+| 桌面 | Chrome/Edge | ✅ | 支持安装为 PWA |
+
+---
+
+### 方案二:Fullscreen API(推荐 ⭐⭐⭐⭐)
+
+#### 2.1 实现原理
+使用 JavaScript **Fullscreen API** 请求进入全屏模式,隐藏浏览器 UI。
+
+#### 2.2 实现代码
+
+**工具函数** (`src/utils/fullscreen.ts`):
+```typescript
+/**
+ * 检测是否支持全屏 API
+ */
+export const isFullscreenSupported = (): boolean => {
+  return !!(
+    document.fullscreenEnabled ||
+    (document as any).webkitFullscreenEnabled ||
+    (document as any).mozFullScreenEnabled ||
+    (document as any).msFullscreenEnabled
+  );
+};
+
+/**
+ * 进入全屏模式
+ */
+export const requestFullscreen = async (): Promise<boolean> => {
+  const element = document.documentElement;
+  
+  try {
+    if (element.requestFullscreen) {
+      await element.requestFullscreen();
+    } else if ((element as any).webkitRequestFullscreen) {
+      await (element as any).webkitRequestFullscreen();
+    } else if ((element as any).mozRequestFullScreen) {
+      await (element as any).mozRequestFullScreen();
+    } else if ((element as any).msRequestFullscreen) {
+      await (element as any).msRequestFullscreen();
+    } else {
+      return false;
+    }
+    return true;
+  } catch (error) {
+    console.error('进入全屏失败:', error);
+    return false;
+  }
+};
+
+/**
+ * 退出全屏模式
+ */
+export const exitFullscreen = async (): Promise<boolean> => {
+  try {
+    if (document.exitFullscreen) {
+      await document.exitFullscreen();
+    } else if ((document as any).webkitExitFullscreen) {
+      await (document as any).webkitExitFullscreen();
+    } else if ((document as any).mozCancelFullScreen) {
+      await (document as any).mozCancelFullScreen();
+    } else if ((document as any).msExitFullscreen) {
+      await (document as any).msExitFullscreen();
+    } else {
+      return false;
+    }
+    return true;
+  } catch (error) {
+    console.error('退出全屏失败:', error);
+    return false;
+  }
+};
+
+/**
+ * 检测是否处于全屏状态
+ */
+export const isFullscreen = (): boolean => {
+  return !!(
+    document.fullscreenElement ||
+    (document as any).webkitFullscreenElement ||
+    (document as any).mozFullScreenElement ||
+    (document as any).msFullscreenElement
+  );
+};
+```
+
+**在页面中使用**:
+```typescript
+import { requestFullscreen, isFullscreenSupported } from '@/utils/fullscreen';
+
+// 页面加载时自动进入全屏(可选)
+useEffect(() => {
+  if (isFullscreenSupported() && !isFullscreen()) {
+    requestFullscreen().catch(console.error);
+  }
+}, []);
+```
+
+#### 2.3 优势
+- ✅ **自动触发**,无需用户手动操作
+- ✅ **完全隐藏浏览器UI**,获得完整高度
+- ✅ **支持监听全屏状态变化**
+- ✅ **桌面浏览器也支持**
+
+#### 2.4 限制
+- ⚠️ **需要用户授权**(浏览器会显示提示)
+- ⚠️ **移动端支持有限**(iOS Safari 不支持,Android Chrome 部分支持)
+- ⚠️ **可能被浏览器阻止**(需要用户交互触发)
+
+#### 2.5 兼容性
+| 平台 | 浏览器 | 支持情况 | 备注 |
+|------|--------|---------|------|
+| iOS | Safari | ❌ | 不支持 Fullscreen API |
+| Android | Chrome | ⚠️ 部分支持 | 需要用户交互触发 |
+| 桌面 | Chrome/Edge | ✅ | 完全支持 |
+| 桌面 | Firefox | ✅ | 完全支持 |
+
+---
+
+### 方案三:调整适配基准(推荐 ⭐⭐⭐)
+
+#### 3.1 实现原理
+**不按 812px 设计稿高度适配**,而是按**实际可视高度**动态适配。
+
+#### 3.2 实现方式
+
+**修改 `src/utils/viewport.ts`**:
+```typescript
+/**
+ * 获取实际可视高度(考虑浏览器UI)
+ */
+const getActualViewportHeight = (): number => {
+  // 优先使用 visualViewport(更准确)
+  if (window.visualViewport?.height) {
+    return window.visualViewport.height;
+  }
+  // 降级使用 innerHeight
+  return window.innerHeight;
+};
+
+/**
+ * px 转 vh(基于实际可视高度)
+ */
+export const px2vh = (px: number): string => {
+  // 不再使用固定的 812px 基准
+  // 而是使用实际可视高度
+  const actualHeight = getActualViewportHeight();
+  const ratio = (px / actualHeight) * 100;
+  return `calc(${ratio} * var(--vh, 1vh))`;
+};
+```
+
+**修改 `src/main.tsx` 中的 `--vh` 计算**:
+```typescript
+const setViewportHeight = () => {
+  // 使用实际可视高度(而非设计稿 812px)
+  const actualHeight = window.visualViewport?.height || window.innerHeight;
+  const vh = actualHeight * 0.01;
+  document.documentElement.style.setProperty('--vh', `${vh}px`);
+};
+```
+
+#### 3.3 优势
+- ✅ **自动适配**,无需用户操作
+- ✅ **兼容所有浏览器**,包括微信内置浏览器
+- ✅ **实时响应**地址栏/工具栏显示/隐藏
+
+#### 3.4 限制
+- ⚠️ **元素会变小**(因为基准高度变小)
+- ⚠️ **设计稿比例会改变**(812px → 667px,缩小约 18%)
+- ⚠️ **需要重新调整设计**,按实际可视高度设计
+
+#### 3.5 适用场景
+- ✅ 接受元素变小的情况
+- ✅ 需要兼容所有浏览器(包括微信)
+- ✅ 设计可以按实际可视高度调整
+
+---
+
+### 方案四:混合方案(推荐 ⭐⭐⭐⭐⭐)
+
+#### 4.1 实现原理
+**检测是否全屏/PWA模式**,动态切换适配基准:
+- **全屏/PWA模式**: 使用 812px 基准(设计稿高度)
+- **普通浏览器**: 使用实际可视高度基准
+
+#### 4.2 实现方式
+
+**修改 `src/utils/viewport.ts`**:
+```typescript
+/**
+ * 检测是否处于全屏/PWA模式
+ */
+const isFullscreenMode = (): boolean => {
+  // 检测 1: Fullscreen API
+  if (document.fullscreenElement || 
+      (document as any).webkitFullscreenElement) {
+    return true;
+  }
+  
+  // 检测 2: PWA standalone 模式
+  if (window.matchMedia('(display-mode: standalone)').matches) {
+    return true;
+  }
+  
+  // 检测 3: 添加到主屏幕(iOS)
+  if ((window.navigator as any).standalone === true) {
+    return true;
+  }
+  
+  return false;
+};
+
+/**
+ * 获取适配基准高度
+ */
+const getBaseHeight = (): number => {
+  const DESIGN_HEIGHT = 812; // 设计稿高度
+  
+  if (isFullscreenMode()) {
+    // 全屏模式:使用设计稿高度
+    return DESIGN_HEIGHT;
+  }
+  
+  // 普通浏览器:使用实际可视高度
+  return window.visualViewport?.height || window.innerHeight;
+};
+
+/**
+ * px 转 vh(智能适配)
+ */
+export const px2vh = (px: number): string => {
+  const baseHeight = getBaseHeight();
+  const ratio = (px / baseHeight) * 100;
+  return `calc(${ratio} * var(--vh, 1vh))`;
+};
+```
+
+**修改 `src/main.tsx`**:
+```typescript
+const setViewportHeight = () => {
+  const DESIGN_HEIGHT = 812;
+  const isFullscreen = isFullscreenMode();
+  
+  // 全屏模式:使用设计稿高度
+  // 普通模式:使用实际可视高度
+  const baseHeight = isFullscreen 
+    ? DESIGN_HEIGHT 
+    : (window.visualViewport?.height || window.innerHeight);
+  
+  const vh = baseHeight * 0.01;
+  document.documentElement.style.setProperty('--vh', `${vh}px`);
+};
+```
+
+#### 4.3 优势
+- ✅ **最佳体验**:全屏时使用完整 812px,普通浏览器时自适应
+- ✅ **兼容性好**:支持所有浏览器
+- ✅ **自动切换**:无需用户手动操作
+
+#### 4.4 限制
+- ⚠️ **普通浏览器模式**元素仍会变小
+- ⚠️ **需要同时实现** PWA 或 Fullscreen API
+
+---
+
+## 三、方案对比总结
+
+| 方案 | 全屏效果 | 兼容性 | 用户体验 | 实现难度 | 推荐度 |
+|------|---------|--------|---------|---------|--------|
+| **PWA 全屏** | ✅ 完全隐藏 | ⚠️ 需安装 | ⭐⭐⭐⭐⭐ | 简单 | ⭐⭐⭐⭐⭐ |
+| **Fullscreen API** | ✅ 完全隐藏 | ⚠️ 移动端有限 | ⭐⭐⭐⭐ | 中等 | ⭐⭐⭐⭐ |
+| **调整适配基准** | ❌ 元素变小 | ✅ 全兼容 | ⭐⭐⭐ | 简单 | ⭐⭐⭐ |
+| **混合方案** | ✅ 智能切换 | ✅ 全兼容 | ⭐⭐⭐⭐⭐ | 中等 | ⭐⭐⭐⭐⭐ |
+
+---
+
+## 四、推荐实施方案
+
+### 4.1 最佳方案:PWA + 混合适配
+
+**步骤 1**: 创建 `public/manifest.json`
+```json
+{
+  "name": "Lumi",
+  "short_name": "Lumi",
+  "start_url": "/",
+  "display": "standalone",
+  "background_color": "#E8E8E8",
+  "theme_color": "#667eea",
+  "orientation": "portrait"
+}
+```
+
+**步骤 2**: 在 `index.html` 中引入
+```html
+<link rel="manifest" href="/manifest.json">
+```
+
+**步骤 3**: 实现混合适配逻辑(见方案四)
+
+**步骤 4**: 引导用户添加到主屏幕
+- iOS Safari: 显示"添加到主屏幕"提示
+- Android Chrome: 显示"安装应用"横幅
+
+### 4.2 备选方案:Fullscreen API + 混合适配
+
+如果 PWA 不支持(如微信内置浏览器),可以:
+1. 页面加载时检测是否支持 Fullscreen API
+2. 支持则自动请求全屏
+3. 不支持则使用实际可视高度适配
+
+---
+
+## 五、实施建议
+
+### 5.1 优先级
+1. **PWA 配置**(最简单,效果最好)
+2. **混合适配逻辑**(智能切换基准)
+3. **Fullscreen API**(作为补充)
+
+### 5.2 注意事项
+- ⚠️ **微信内置浏览器**不支持 PWA 和 Fullscreen API,只能使用方案三
+- ⚠️ **iOS Safari** 需要用户手动添加到主屏幕
+- ⚠️ **首次访问**普通浏览器模式,元素仍会变小(可显示引导提示)
+
+### 5.3 用户体验优化
+- 显示"添加到主屏幕"引导(PWA 模式)
+- 显示"进入全屏"按钮(Fullscreen API)
+- 普通浏览器模式显示友好提示
+
+---
+
+## 六、技术细节
+
+### 6.1 检测 PWA 模式
+```typescript
+// 方法 1: CSS Media Query
+const isPWA = window.matchMedia('(display-mode: standalone)').matches;
+
+// 方法 2: iOS Safari
+const isIOSPWA = (window.navigator as any).standalone === true;
+
+// 方法 3: Android Chrome
+const isAndroidPWA = window.matchMedia('(display-mode: standalone)').matches;
+```
+
+### 6.2 检测 Fullscreen 状态
+```typescript
+const isFullscreen = !!(
+  document.fullscreenElement ||
+  (document as any).webkitFullscreenElement ||
+  (document as any).mozFullScreenElement
+);
+```
+
+### 6.3 监听全屏状态变化
+```typescript
+document.addEventListener('fullscreenchange', handleFullscreenChange);
+document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
+```
+
+---
+
+## 七、总结
+
+### 7.1 核心问题
+移动端浏览器地址栏/工具栏占用空间,导致可视高度 < 812px,元素显示变小。
+
+### 7.2 最佳解决方案
+**PWA 全屏模式 + 混合适配**:
+- PWA 模式:完全隐藏浏览器UI,获得完整 812px
+- 普通浏览器:使用实际可视高度适配,保证兼容性
+
+### 7.3 实施步骤
+1. ✅ 创建 `manifest.json` 配置 PWA
+2. ✅ 实现混合适配逻辑(检测全屏状态)
+3. ✅ 引导用户添加到主屏幕
+4. ✅ 可选:实现 Fullscreen API 作为补充
+