zlwu před 11 měsíci
rodič
revize
07f6c4431a

+ 104 - 16
backend/stt_service.py

@@ -58,6 +58,10 @@ class STTCallback(OmniRealtimeCallback):
         self.speech_start_count = 0   # 语音开始次数
         self.speech_stop_count = 0    # 语音停止次数
         
+        # 事件接收统计(用于诊断)
+        self.event_count = 0  # 收到的事件总数
+        self.received_events = []  # 收到的事件类型列表(最多保存20个)
+        
         # 事件处理器映射(参考官方demo)
         self.handlers = {
             'session.created': self._handle_session_created,
@@ -101,6 +105,30 @@ class STTCallback(OmniRealtimeCallback):
             event_type = response.get('type')
             handler = self.handlers.get(event_type)
             
+            # 🔥 更新事件接收统计
+            self.event_count += 1
+            if len(self.received_events) < 20:
+                self.received_events.append(event_type)
+            
+            # 🔥 打印STT接口的原始返回日志(完整JSON)
+            import json
+            try:
+                response_json = json.dumps(response, ensure_ascii=False, indent=2)
+                logger.info(
+                    f"[STT原始返回] request_id={self.request_id}, "
+                    f"事件#{self.event_count}, "
+                    f"事件类型={event_type}\n"
+                    f"完整响应数据:\n{response_json}"
+                )
+            except Exception as json_error:
+                logger.warning(
+                    f"[STT原始返回] request_id={self.request_id}, "
+                    f"事件#{self.event_count}, "
+                    f"事件类型={event_type}, "
+                    f"JSON序列化失败: {json_error}, "
+                    f"原始响应={response}"
+                )
+            
             # 调试日志:记录所有事件(高频事件简化输出)
             if event_type in ['input_audio_buffer.speech_started', 'input_audio_buffer.speech_stopped']:
                 logger.debug(
@@ -201,8 +229,8 @@ class STTCallback(OmniRealtimeCallback):
             # 记录首个文本生成时间
             if self.first_text_time is None and full_text:
                 self.first_text_time = current_time
-                logger.debug(
-                    f"[STT调试] request_id={self.request_id}, "
+                logger.info(
+                    f"[STT] request_id={self.request_id}, "
                     f"首个文本生成, 文本={full_text[:30]}{'...' if len(full_text) > 30 else ''}"
                 )
             
@@ -225,13 +253,21 @@ class STTCallback(OmniRealtimeCallback):
                 self._async_call(self.on_text_update(full_text, False))
     
     def _handle_speech_start(self, response: dict):
-        """处理语音开始事件(静默处理,减少日志)"""
+        """处理语音开始事件"""
         self.speech_detected = True
         self.speech_start_count += 1
+        logger.debug(
+            f"[STT调试] request_id={self.request_id}, "
+            f"检测到语音开始, speech_start_count={self.speech_start_count}"
+        )
     
     def _handle_speech_stop(self, response: dict):
-        """处理语音停止事件(静默处理,减少日志)"""
+        """处理语音停止事件"""
         self.speech_stop_count += 1
+        logger.debug(
+            f"[STT调试] request_id={self.request_id}, "
+            f"检测到语音停止, speech_stop_count={self.speech_stop_count}"
+        )
     
     def _handle_error(self, response: dict):
         """处理错误事件"""
@@ -491,12 +527,48 @@ class STTSession:
             return False
         
         try:
+            # 🔥 音频数据验证和统计(用于诊断问题)
+            import struct
+            try:
+                # 将PCM字节数据转换为Int16数组
+                sample_count = len(audio_data) // 2
+                if sample_count > 0:
+                    samples = struct.unpack(f'<{sample_count}h', audio_data)
+                    max_amplitude = max(abs(s) for s in samples) if samples else 0
+                    avg_amplitude = sum(abs(s) for s in samples) // len(samples) if samples else 0
+                    zero_count = sum(1 for s in samples if s == 0)
+                    zero_ratio = zero_count / len(samples) if samples else 0.0
+                    
+                    # 记录音频统计信息(仅第一个chunk和异常情况)
+                    if not hasattr(self, '_audio_chunk_count'):
+                        self._audio_chunk_count = 0
+                    self._audio_chunk_count += 1
+                    
+                    # 第一个chunk或异常情况(全零或振幅过低)时记录详细信息
+                    if self._audio_chunk_count == 1 or max_amplitude < 100 or zero_ratio > 0.9:
+                        logger.info(
+                            f"[STT音频统计] request_id={self.request_id}, "
+                            f"chunk={self._audio_chunk_count}, "
+                            f"长度={len(audio_data)} bytes, "
+                            f"采样数={sample_count}, "
+                            f"最大振幅={max_amplitude}, "
+                            f"平均振幅={avg_amplitude}, "
+                            f"零值比例={zero_ratio:.2%}, "
+                            f"是否静音={'是' if max_amplitude < 100 or zero_ratio > 0.9 else '否'}"
+                        )
+            except Exception as stats_error:
+                logger.warning(
+                    f"[STT音频统计] request_id={self.request_id}, "
+                    f"统计计算失败: {stats_error}"
+                )
+            
             # Base64编码(API要求)
             import base64
             audio_b64 = base64.b64encode(audio_data).decode('ascii')
             
-            logger.debug(
-                f"[STT调试] request_id={self.request_id}, "
+            # 使用INFO级别记录音频准备发送(便于排查问题)
+            logger.info(
+                f"[STT] request_id={self.request_id}, "
                 f"准备发送音频: 原始长度={len(audio_data)} bytes, "
                 f"Base64长度={len(audio_b64)} chars"
             )
@@ -509,9 +581,11 @@ class STTSession:
                     timeout=5.0  # 5秒发送超时
                 )
                 send_duration = (time.time() - send_start) * 1000
-                logger.debug(
-                    f"[STT调试] request_id={self.request_id}, "
-                    f"音频发送成功, 耗时={send_duration:.1f}ms"
+                # 使用INFO级别记录音频发送成功(便于排查问题)
+                logger.info(
+                    f"[STT] request_id={self.request_id}, "
+                    f"音频发送成功, 原始长度={len(audio_data)} bytes, "
+                    f"耗时={send_duration:.1f}ms"
                 )
             except asyncio.TimeoutError:
                 raise Exception("发送音频数据超时(5秒)")
@@ -575,8 +649,8 @@ class STTSession:
                     timeout=5.0  # 5秒提交超时
                 )
                 commit_duration = (time.time() - commit_start) * 1000
-                logger.debug(
-                    f"[STT调试] request_id={self.request_id}, "
+                logger.info(
+                    f"[STT] request_id={self.request_id}, "
                     f"音频提交成功, 耗时={commit_duration:.1f}ms"
                 )
             except asyncio.TimeoutError:
@@ -626,23 +700,37 @@ class STTSession:
             await asyncio.wait_for(self.callback.final_event.wait(), timeout=timeout)
             wait_duration = (time.time() - wait_start) * 1000
             result_text = self.callback.get_text()
-            logger.debug(
-                f"[STT调试] request_id={self.request_id}, "
+            logger.info(
+                f"[STT] request_id={self.request_id}, "
                 f"收到最终识别结果, 等待耗时={wait_duration:.1f}ms, "
-                f"文本长度={len(result_text)}"
+                f"文本长度={len(result_text)}, "
+                f"文本内容={result_text[:50]}{'...' if len(result_text) > 50 else ''}"
             )
             return result_text
             
         except asyncio.TimeoutError:
-            # 超时后,检查是否有中间结果
+            # 超时后,检查是否有中间结果和语音检测状态
             wait_duration = (time.time() - wait_start) * 1000
             current_text = self.callback.get_text()
+            speech_detected = self.callback.speech_detected
+            speech_start_count = self.callback.speech_start_count
+            speech_stop_count = self.callback.speech_stop_count
+            
+            # 🔥 输出事件接收统计(用于诊断)
+            event_count = self.callback.event_count
+            received_events = self.callback.received_events
+            
             logger.warning(
                 f"[STT警告] request_id={self.request_id}, "
                 f"错误类型=等待识别结果超时, "
                 f"超时时间={timeout}秒, "
                 f"等待耗时={wait_duration:.1f}ms, "
-                f"当前识别文本={current_text}"
+                f"当前识别文本={current_text}, "
+                f"语音检测状态=speech_detected={speech_detected}, "
+                f"speech_start_count={speech_start_count}, "
+                f"speech_stop_count={speech_stop_count}, "
+                f"收到事件总数={event_count}, "
+                f"收到的事件类型={received_events}"
             )
             return current_text
         except Exception as e:

+ 18 - 2
backend/websocket_handler.py

@@ -304,12 +304,28 @@ class WebSocketHandler:
             
             # 获取识别结果
             if not text:
-                await self.send_error(request_id, "STT_ERROR", "未检测到有效语音,请重新录音")
+                # 检查语音检测状态,提供更详细的错误信息
+                callback = session.callback
+                speech_detected = callback.speech_detected if callback else False
+                speech_start_count = callback.speech_start_count if callback else 0
+                
+                if not speech_detected and speech_start_count == 0:
+                    error_msg = "未检测到有效语音,请检查麦克风权限或重新录音"
+                else:
+                    error_msg = f"语音识别超时,已检测到语音但未返回识别结果(speech_detected={speech_detected}, speech_start_count={speech_start_count})"
+                
+                logger.error(
+                    f"[{request_id}] ❌ STT识别失败: {error_msg}, "
+                    f"音频块数={message.totalChunks}, "
+                    f"录音时长={message.speechDuration}ms"
+                )
+                
+                await self.send_error(request_id, "STT_ERROR", error_msg)
                 request_logger.log_error(
                     user_id=self.user_id,
                     request_id=request_id,
                     error_code="STT_ERROR",
-                    error_message="未检测到有效语音,请重新录音",
+                    error_message=error_msg,
                     stage="stt"
                 )
                 await self.cleanup_request(request_id)

+ 1 - 1
frontend/src/assets/progress-ring.svg

@@ -1,4 +1,4 @@
-<svg width="114" height="103" viewBox="0 0 114 103" fill="none" xmlns="http://www.w3.org/2000/svg">
+<svg width="114" height="114" viewBox="0 0 114 114" fill="none" xmlns="http://www.w3.org/2000/svg">
 <mask id="path-1-inside-1_354_3236" fill="white">
 <path d="M26.2678 100.89C25.1844 102.437 23.0446 102.821 21.5651 101.647C13.0685 94.9039 6.65238 85.8584 3.10544 75.5574C-0.813494 64.176 -1.02871 51.8461 2.49063 40.3348C6.00997 28.8236 13.0832 18.722 22.6965 11.4778C32.3099 4.23359 43.9698 0.218758 56.0052 0.00868127C68.0406 -0.201396 79.8335 3.40407 89.6939 10.3083C99.5542 17.2126 106.976 27.0612 110.895 38.4426C114.813 49.824 115.029 62.1539 111.509 73.6652C108.324 84.0838 102.228 93.3476 93.9715 100.383C92.5339 101.608 90.382 101.299 89.2452 99.7909C88.1085 98.2824 88.4188 96.1466 89.8463 94.9098C96.9536 88.7519 102.205 80.7022 104.968 71.6654C108.065 61.5355 107.876 50.6852 104.427 40.6695C100.979 30.6539 94.4477 21.9871 85.7706 15.9113C77.0935 9.83558 66.7157 6.66277 56.1246 6.84764C45.5335 7.03251 35.2727 10.5656 26.813 16.9404C18.3532 23.3153 12.1288 32.2047 9.03175 42.3346C5.93473 52.4645 6.12413 63.3149 9.57279 73.3305C12.6493 82.2654 18.1788 90.1269 25.4967 96.033C26.9666 97.2192 27.3512 99.3429 26.2678 100.89Z"/>
 </mask>

+ 13 - 0
frontend/src/index.css

@@ -111,6 +111,19 @@ body[data-too-small="true"] {
   overflow: hidden;
 }
 
+/* 移动端 Chrome 高度修复:使用 100dvh 或 -webkit-fill-available */
+@supports (height: 100dvh) {
+  .content-card.min-h-screen {
+    min-height: 100dvh;
+  }
+}
+
+@supports not (height: 100dvh) {
+  .content-card.min-h-screen {
+    min-height: -webkit-fill-available;
+  }
+}
+
 /* 极小屏幕降级:固定最小高度,允许滚动 */
 .content-card[data-too-small="true"] {
   height: auto;

+ 1 - 1
frontend/src/pages/ChatBotPage copy.tsx

@@ -850,7 +850,7 @@ export const ChatBotPage = () => {
               className="pointer-events-none"
               style={{
                 position: 'absolute',
-                top: 0,
+                top: '5px',
                 left: 0,
                 width: '100%',
                 height: '100%',

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

@@ -879,7 +879,7 @@ export const ChatBotPage = () => {
               className="pointer-events-none"
               style={{
                 position: 'absolute',
-                top: 0,
+                top: '4px',
                 left: 0,
                 width: '100%',
                 height: '100%',
@@ -892,7 +892,7 @@ export const ChatBotPage = () => {
               className="pointer-events-none"
               style={{
                 position: 'absolute',
-                top: '5px',
+                top: '4px',
                 left: 0,
                 width: '100%',
                 height: '100%',

+ 132 - 17
frontend/src/pages/GreetingPage.tsx

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
 import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '../utils/storage';
 import { getUserInfo, updateUsername } from '../utils/api';
 import { px2rem } from '../utils/rem';
+import { browser } from '../utils/browserDetect';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 
@@ -93,13 +94,82 @@ export const GreetingPage = () => {
     navigate('/welcome');
   };
 
+  // 检测是否为移动端 Chrome(需要在 updateLayoutMode 之前定义)
+  const isMobileChrome = browser.isMobile && browser.isChrome && browser.isAndroid;
+  
+  // 动态计算视口高度(兼容所有浏览器)
+  const [viewportHeight, setViewportHeight] = useState(() => {
+    if (typeof window === 'undefined') return 800;
+    // 优先使用 visualViewport,如果不可用则使用 innerHeight
+    // Chrome 移动端可能需要特殊处理
+    return window.visualViewport?.height ?? window.innerHeight;
+  });
+
   const updateLayoutMode = useCallback(() => {
     if (typeof window === 'undefined') {
       return;
     }
-    const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
-    setIsCompactLayout(viewportHeight < 720);
-  }, []);
+    
+    // 获取视口高度 - 兼容所有浏览器
+    let height: number;
+    if (window.visualViewport) {
+      height = window.visualViewport.height;
+    } else {
+      height = window.innerHeight;
+    }
+    
+    // Chrome 移动端特殊处理:如果 innerHeight 和 visualViewport 差异很大,使用 innerHeight
+    // 因为 Chrome 的 visualViewport 可能不准确
+    if (isMobileChrome && window.visualViewport) {
+      const innerHeight = window.innerHeight;
+      const visualHeight = window.visualViewport.height;
+      // 如果差异超过 100px,说明 visualViewport 可能不准确,使用 innerHeight
+      if (Math.abs(innerHeight - visualHeight) > 100) {
+        height = innerHeight;
+      }
+    }
+    
+    setViewportHeight(height);
+    setIsCompactLayout(height < 720);
+  }, [isMobileChrome]);
+  
+  // 计算 Chrome 底部安全区域(设置合理的初始值)
+  const [chromeBottomPadding, setChromeBottomPadding] = useState(isMobileChrome ? 60 : 0);
+  
+  useEffect(() => {
+    if (!isMobileChrome || typeof window === 'undefined') {
+      return;
+    }
+    
+    // 计算底部工具栏高度
+    const calculateBottomPadding = () => {
+      const visualHeight = window.visualViewport?.height ?? window.innerHeight;
+      const windowHeight = window.innerHeight;
+      const bottomBarHeight = windowHeight - visualHeight;
+      
+      // 如果检测到底部工具栏,添加额外 padding
+      // Chrome 底部工具栏通常约 48-56px,加上安全边距
+      // 限制最大 padding 为 100px,避免按钮被推出视窗
+      if (bottomBarHeight > 0) {
+        const calculatedPadding = Math.max(bottomBarHeight + 20, 60);
+        setChromeBottomPadding(Math.min(calculatedPadding, 100));
+      } else {
+        // 即使没有检测到,也添加基础安全边距(降低到 60px)
+        setChromeBottomPadding(60);
+      }
+    };
+    
+    calculateBottomPadding();
+    
+    // 监听视口变化
+    const visualViewport = window.visualViewport;
+    if (visualViewport) {
+      visualViewport.addEventListener('resize', calculateBottomPadding);
+      return () => {
+        visualViewport.removeEventListener('resize', calculateBottomPadding);
+      };
+    }
+  }, [isMobileChrome]);
 
   useEffect(() => {
     updateLayoutMode();
@@ -117,15 +187,57 @@ export const GreetingPage = () => {
     };
   }, [updateLayoutMode]);
 
-  const contentPaddingTop = isCompactLayout ? px2rem(110) : px2rem(180);
-  const sectionGap = isCompactLayout ? px2rem(28) : px2rem(40);
-  const avatarMaxHeight = isCompactLayout ? '38vh' : '50vh';
-  const avatarMinHeight = isCompactLayout ? px2rem(160) : px2rem(220);
-  const avatarPaddingBottom = isCompactLayout ? px2rem(24) : px2rem(40);
+  // 基于视口高度的动态计算(等比例缩放)
+  // 基准:720px 高度对应紧凑布局,900px+ 对应正常布局
+  const baseHeight = 900; // 正常布局的基准高度
+  const compactThreshold = 720; // 紧凑布局的阈值
+  
+  // 计算缩放比例(0.6 - 1.0)
+  const scale = Math.max(0.6, Math.min(1.0, viewportHeight / baseHeight));
+  
+  // 正常布局的值
+  const normalPaddingTop = 180;
+  const normalSectionGap = 40;
+  const normalAvatarMinHeight = 220;
+  const normalAvatarPaddingBottom = 40;
+  const normalButtonWidth = 163;
+  const normalButtonHeight = 56;
+  
+  // 紧凑布局的值
+  const compactPaddingTop = 110;
+  const compactSectionGap = 28;
+  const compactAvatarMinHeight = 160;
+  const compactAvatarPaddingBottom = 24;
+  const compactButtonWidth = 130;
+  const compactButtonHeight = 45;
+  
+  // 根据视口高度在正常和紧凑之间插值
+  const getInterpolatedValue = (normal: number, compact: number) => {
+    if (viewportHeight >= baseHeight) return normal;
+    if (viewportHeight <= compactThreshold) return compact;
+    // 线性插值
+    const ratio = (viewportHeight - compactThreshold) / (baseHeight - compactThreshold);
+    return compact + (normal - compact) * ratio;
+  };
+  
+  const contentPaddingTop = px2rem(getInterpolatedValue(normalPaddingTop, compactPaddingTop));
+  const sectionGap = px2rem(getInterpolatedValue(normalSectionGap, compactSectionGap));
+  const avatarMinHeight = px2rem(getInterpolatedValue(normalAvatarMinHeight, compactAvatarMinHeight));
+  const avatarPaddingBottom = px2rem(getInterpolatedValue(normalAvatarPaddingBottom, compactAvatarPaddingBottom));
+  const buttonWidth = px2rem(getInterpolatedValue(normalButtonWidth, compactButtonWidth));
+  const buttonHeight = px2rem(getInterpolatedValue(normalButtonHeight, compactButtonHeight));
+  
+  // 头像最大高度基于视口高度动态计算
+  const avatarMaxHeight = `${Math.max(30, Math.min(50, scale * 50))}vh`;
+  
+  // 移动端 Chrome 需要额外的底部 padding,避免按钮被浏览器底部工具栏遮挡
+  const contentPaddingBottom = isMobileChrome 
+    ? px2rem(chromeBottomPadding || 60) // 动态计算或使用默认值(降低到 60px)
+    : px2rem(32);
 
   return (
     <div className="mobile-container fixed top-0 left-0 animate-fade-in">
-      <div className="content-card safe-area-inset relative flex flex-col min-h-screen overflow-y-auto">
+      <div className="content-card safe-area-inset relative flex flex-col h-screen overflow-hidden">
         {/* 返回按钮 - 响应式定位,与 Replay intro 垂直居中对齐 */}
         <button 
           className="absolute z-10 flex items-center justify-center 
@@ -168,13 +280,14 @@ export const GreetingPage = () => {
         </button>
 
         <div
-          className="flex flex-col flex-1 overflow-hidden"
+          className="flex flex-col flex-1 overflow-y-auto"
           style={{
             paddingLeft: px2rem(32),
             paddingRight: px2rem(32),
             paddingTop: contentPaddingTop,
-            paddingBottom: px2rem(32),
+            paddingBottom: contentPaddingBottom,
             gap: sectionGap,
+            minHeight: 0, // 允许 flex 子元素缩小
           }}
         >
           {/* 标题区域 */}
@@ -278,14 +391,16 @@ export const GreetingPage = () => {
             ></div>
           </div>
 
-          {/* 角色头像区域 */}
+          {/* 角色头像区域 - 使用 flex-shrink 确保按钮区域可见 */}
           <div
-            className="flex-1 flex items-center justify-center pointer-events-none"
+            className="flex items-center justify-center pointer-events-none"
             style={{
               width: '100%',
               maxHeight: avatarMaxHeight,
               minHeight: avatarMinHeight,
               paddingBottom: avatarPaddingBottom,
+              flex: '1 1 0', // 允许缩小,确保按钮可见
+              overflow: 'hidden', // 防止内容溢出
             }}
           >
             <img
@@ -295,16 +410,16 @@ export const GreetingPage = () => {
             />
           </div>
 
-          {/* 确认按钮 */}
-          <div className="w-full flex justify-center items-center">
+          {/* 确认按钮 - 固定位置,确保始终可见 */}
+          <div className="w-full flex justify-center items-center flex-shrink-0">
             <button
               type="button"
               onClick={handleSubmit}
               disabled={!userName.trim()}
               className="relative text-white font-semibold tracking-[0.5px] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
               style={{
-                width: px2rem(163),
-                height: px2rem(56),
+                width: buttonWidth,
+                height: buttonHeight,
                 borderRadius: px2rem(100),
                 backgroundImage: `url(${ConfirmBtnImage})`,
                 backgroundSize: 'cover',

+ 138 - 21
frontend/src/pages/LumiNamingPage.tsx

@@ -3,7 +3,8 @@ import { useNavigate } from 'react-router-dom';
 import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '../utils/storage';
 import { updateBotName } from '../utils/api';
 import { px2rem } from '../utils/rem';
-import LumiImage from '../assets/lumi.png';
+import { browser } from '../utils/browserDetect';
+import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 
 export const LumiNamingPage = () => {
@@ -11,6 +12,17 @@ export const LumiNamingPage = () => {
   const [lumiName, setLumiName] = useState('');
   const [isFocused, setIsFocused] = useState(false);
   const [isCompactLayout, setIsCompactLayout] = useState(false);
+  
+  // 检测是否为移动端 Chrome(需要在 updateLayoutMode 之前定义)
+  const isMobileChrome = browser.isMobile && browser.isChrome && browser.isAndroid;
+  
+  // 动态计算视口高度(兼容所有浏览器)
+  const [viewportHeight, setViewportHeight] = useState(() => {
+    if (typeof window === 'undefined') return 800;
+    // 优先使用 visualViewport,如果不可用则使用 innerHeight
+    // Chrome 移动端可能需要特殊处理
+    return window.visualViewport?.height ?? window.innerHeight;
+  });
 
   // 组件加载时从 localStorage 读取 Lumi 姓名,并记录当前页面
   useEffect(() => {
@@ -65,9 +77,67 @@ export const LumiNamingPage = () => {
     if (typeof window === 'undefined') {
       return;
     }
-    const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
-    setIsCompactLayout(viewportHeight < 720);
-  }, []);
+    
+    // 获取视口高度 - 兼容所有浏览器
+    let height: number;
+    if (window.visualViewport) {
+      height = window.visualViewport.height;
+    } else {
+      height = window.innerHeight;
+    }
+    
+    // Chrome 移动端特殊处理:如果 innerHeight 和 visualViewport 差异很大,使用 innerHeight
+    // 因为 Chrome 的 visualViewport 可能不准确
+    if (isMobileChrome && window.visualViewport) {
+      const innerHeight = window.innerHeight;
+      const visualHeight = window.visualViewport.height;
+      // 如果差异超过 100px,说明 visualViewport 可能不准确,使用 innerHeight
+      if (Math.abs(innerHeight - visualHeight) > 100) {
+        height = innerHeight;
+      }
+    }
+    
+    setViewportHeight(height);
+    setIsCompactLayout(height < 720);
+  }, [isMobileChrome]);
+
+  // 计算 Chrome 底部安全区域(设置合理的初始值)
+  const [chromeBottomPadding, setChromeBottomPadding] = useState(isMobileChrome ? 60 : 0);
+  
+  useEffect(() => {
+    if (!isMobileChrome || typeof window === 'undefined') {
+      return;
+    }
+    
+    // 计算底部工具栏高度
+    const calculateBottomPadding = () => {
+      const visualHeight = window.visualViewport?.height ?? window.innerHeight;
+      const windowHeight = window.innerHeight;
+      const bottomBarHeight = windowHeight - visualHeight;
+      
+      // 如果检测到底部工具栏,添加额外 padding
+      // Chrome 底部工具栏通常约 48-56px,加上安全边距
+      // 限制最大 padding 为 100px,避免按钮被推出视窗
+      if (bottomBarHeight > 0) {
+        const calculatedPadding = Math.max(bottomBarHeight + 20, 60);
+        setChromeBottomPadding(Math.min(calculatedPadding, 100));
+      } else {
+        // 即使没有检测到,也添加基础安全边距(降低到 60px)
+        setChromeBottomPadding(60);
+      }
+    };
+    
+    calculateBottomPadding();
+    
+    // 监听视口变化
+    const visualViewport = window.visualViewport;
+    if (visualViewport) {
+      visualViewport.addEventListener('resize', calculateBottomPadding);
+      return () => {
+        visualViewport.removeEventListener('resize', calculateBottomPadding);
+      };
+    }
+  }, [isMobileChrome]);
 
   useEffect(() => {
     updateLayoutMode();
@@ -85,15 +155,57 @@ export const LumiNamingPage = () => {
     };
   }, [updateLayoutMode]);
 
-  const contentPaddingTop = isCompactLayout ? px2rem(110) : px2rem(180);
-  const sectionGap = isCompactLayout ? px2rem(28) : px2rem(40);
-  const avatarMaxHeight = isCompactLayout ? '38vh' : '50vh';
-  const avatarMinHeight = isCompactLayout ? px2rem(160) : px2rem(220);
-  const avatarPaddingBottom = isCompactLayout ? px2rem(24) : px2rem(40);
+  // 基于视口高度的动态计算(等比例缩放)
+  // 基准:720px 高度对应紧凑布局,900px+ 对应正常布局
+  const baseHeight = 900; // 正常布局的基准高度
+  const compactThreshold = 720; // 紧凑布局的阈值
+  
+  // 计算缩放比例(0.6 - 1.0)
+  const scale = Math.max(0.6, Math.min(1.0, viewportHeight / baseHeight));
+  
+  // 正常布局的值
+  const normalPaddingTop = 180;
+  const normalSectionGap = 40;
+  const normalAvatarMinHeight = 220;
+  const normalAvatarPaddingBottom = 40;
+  const normalButtonWidth = 163;
+  const normalButtonHeight = 56;
+  
+  // 紧凑布局的值
+  const compactPaddingTop = 110;
+  const compactSectionGap = 28;
+  const compactAvatarMinHeight = 160;
+  const compactAvatarPaddingBottom = 24;
+  const compactButtonWidth = 130;
+  const compactButtonHeight = 45;
+  
+  // 根据视口高度在正常和紧凑之间插值
+  const getInterpolatedValue = (normal: number, compact: number) => {
+    if (viewportHeight >= baseHeight) return normal;
+    if (viewportHeight <= compactThreshold) return compact;
+    // 线性插值
+    const ratio = (viewportHeight - compactThreshold) / (baseHeight - compactThreshold);
+    return compact + (normal - compact) * ratio;
+  };
+  
+  const contentPaddingTop = px2rem(getInterpolatedValue(normalPaddingTop, compactPaddingTop));
+  const sectionGap = px2rem(getInterpolatedValue(normalSectionGap, compactSectionGap));
+  const avatarMinHeight = px2rem(getInterpolatedValue(normalAvatarMinHeight, compactAvatarMinHeight));
+  const avatarPaddingBottom = px2rem(getInterpolatedValue(normalAvatarPaddingBottom, compactAvatarPaddingBottom));
+  const buttonWidth = px2rem(getInterpolatedValue(normalButtonWidth, compactButtonWidth));
+  const buttonHeight = px2rem(getInterpolatedValue(normalButtonHeight, compactButtonHeight));
+  
+  // 头像最大高度基于视口高度动态计算
+  const avatarMaxHeight = `${Math.max(30, Math.min(50, scale * 50))}vh`;
+  
+  // 移动端 Chrome 需要额外的底部 padding,避免按钮被浏览器底部工具栏遮挡
+  const contentPaddingBottom = isMobileChrome 
+    ? px2rem(chromeBottomPadding || 60) // 动态计算或使用默认值(降低到 60px)
+    : px2rem(32);
 
   return (
     <div className="mobile-container fixed top-0 left-0 animate-fade-in">
-      <div className="content-card safe-area-inset relative flex flex-col min-h-screen overflow-y-auto">
+      <div className="content-card safe-area-inset relative flex flex-col h-screen overflow-hidden">
         {/* 返回按钮 */}
         <button
           className="absolute z-10 flex items-center justify-center 
@@ -119,13 +231,14 @@ export const LumiNamingPage = () => {
         </button>
 
         <div
-          className="flex flex-col flex-1 overflow-hidden"
+          className="flex flex-col flex-1 overflow-y-auto"
           style={{
             paddingLeft: px2rem(32),
             paddingRight: px2rem(32),
             paddingTop: contentPaddingTop,
-            paddingBottom: px2rem(32),
+            paddingBottom: contentPaddingBottom,
             gap: sectionGap,
+            minHeight: 0, // 允许 flex 子元素缩小
           }}
         >
           {/* 标题区域 */}
@@ -135,6 +248,8 @@ export const LumiNamingPage = () => {
               style={{
                 fontSize: isCompactLayout ? px2rem(26) : px2rem(31),
                 lineHeight: isCompactLayout ? px2rem(40) : px2rem(50),
+                whiteSpace: 'nowrap',
+                wordBreak: 'keep-all',
               }}
             >
               What is my name?
@@ -178,7 +293,7 @@ export const LumiNamingPage = () => {
                 onChange={(e) => setLumiName(e.target.value)}
                 onFocus={() => setIsFocused(true)}
                 onBlur={() => setIsFocused(false)}
-                onKeyPress={(e) => {
+                onKeyDown={(e) => {
                   if (e.key === 'Enter') {
                     handleSubmit();
                   }
@@ -227,33 +342,35 @@ export const LumiNamingPage = () => {
             ></div>
           </div>
 
-          {/* 角色头像区域 */}
+          {/* 角色头像区域 - 使用 flex-shrink 确保按钮区域可见 */}
           <div
-            className="flex-1 flex items-center justify-center pointer-events-none"
+            className="flex items-center justify-center pointer-events-none"
             style={{
               width: '100%',
               maxHeight: avatarMaxHeight,
               minHeight: avatarMinHeight,
               paddingBottom: avatarPaddingBottom,
+              flex: '1 1 0', // 允许缩小,确保按钮可见
+              overflow: 'hidden', // 防止内容溢出
             }}
           >
             <img
-              src={LumiImage}
+              src={AvatarZone}
               alt="Character Avatar"
               className="w-full h-full object-contain object-bottom"
             />
           </div>
 
-          {/* 确认按钮 */}
-          <div className="flex justify-center pb-6">
+          {/* 确认按钮 - 固定位置,确保始终可见 */}
+          <div className="w-full flex justify-center items-center flex-shrink-0">
             <button
               type="button"
               onClick={handleSubmit}
               disabled={!lumiName.trim()}
-              className="relative transition-all disabled:opacity-40 disabled:cursor-not-allowed"
+              className="relative text-white font-semibold tracking-[0.5px] transition-all disabled:opacity-40 disabled:cursor-not-allowed"
               style={{
-                width: px2rem(163),
-                height: px2rem(56),
+                width: buttonWidth,
+                height: buttonHeight,
                 borderRadius: px2rem(100),
                 backgroundImage: `url(${ConfirmBtnImage})`,
                 backgroundSize: 'cover',