zlwu il y a 11 mois
Parent
commit
a3df98b1fb

+ 6 - 1
backend/database.py

@@ -56,15 +56,20 @@ class DatabaseManager:
             f"?charset=utf8mb4"
             f"?charset=utf8mb4"
         )
         )
         
         
-        # 创建异步引擎(自动连接池 + 健康检查)
+        # 创建异步引擎(自动连接池 + 健康检查 + 超时配置)
         self.engine = create_async_engine(
         self.engine = create_async_engine(
             mysql_url,
             mysql_url,
             pool_size=settings.MYSQL_POOL_SIZE,
             pool_size=settings.MYSQL_POOL_SIZE,
             max_overflow=settings.MYSQL_MAX_OVERFLOW,
             max_overflow=settings.MYSQL_MAX_OVERFLOW,
             pool_pre_ping=True,  # 🔥 自动健康检查(类似 ping)
             pool_pre_ping=True,  # 🔥 自动健康检查(类似 ping)
             pool_recycle=settings.MYSQL_POOL_RECYCLE,  # 连接回收
             pool_recycle=settings.MYSQL_POOL_RECYCLE,  # 连接回收
+            pool_timeout=5.0,  # 🔥 连接池获取连接超时(秒),避免长时间等待
             echo=False,  # 生产环境关闭 SQL 日志
             echo=False,  # 生产环境关闭 SQL 日志
             future=True,
             future=True,
+            connect_args={
+                "connect_timeout": settings.MYSQL_CONNECT_TIMEOUT,  # 🔥 连接超时(aiomysql 支持)
+                # ⚠️ read_timeout 已移除:aiomysql 不支持此参数,读取超时由 pool_timeout 控制
+            },
         )
         )
         
         
         # 创建异步 Session 工厂
         # 创建异步 Session 工厂

+ 32 - 3
backend/main.py

@@ -203,17 +203,46 @@ async def update_bot_name(user_id: str, request: UpdateBotNameRequest):
             "message": "更新成功"
             "message": "更新成功"
         }
         }
     """
     """
+    import asyncio
+    import time
+    
+    start_time = time.time()
+    
     try:
     try:
-        success = await user_service.update_bot_name(user_id, request.bot_name)
+        # 🔥 添加超时保护(5秒),避免长时间等待
+        success = await asyncio.wait_for(
+            user_service.update_bot_name(user_id, request.bot_name),
+            timeout=5.0
+        )
+        
+        duration = (time.time() - start_time) * 1000
+        
         if success:
         if success:
             return {
             return {
                 "success": True,
                 "success": True,
                 "message": "机器人名字更新成功"
                 "message": "机器人名字更新成功"
             }
             }
         else:
         else:
-            raise HTTPException(status_code=500, detail="更新机器人名字失败")
+            raise HTTPException(
+                status_code=500, 
+                detail=f"更新机器人名字失败(耗时: {duration:.1f}ms)"
+            )
+    except asyncio.TimeoutError:
+        duration = (time.time() - start_time) * 1000
+        logger.error(f"❌ 更新机器人名字超时: user_id={user_id}, 耗时={duration:.1f}ms")
+        raise HTTPException(
+            status_code=504, 
+            detail=f"更新机器人名字超时(耗时: {duration:.1f}ms),请稍后重试"
+        )
+    except HTTPException:
+        raise
     except Exception as e:
     except Exception as e:
-        raise HTTPException(status_code=500, detail=f"更新机器人名字失败: {str(e)}")
+        duration = (time.time() - start_time) * 1000
+        logger.error(f"❌ 更新机器人名字异常: user_id={user_id}, error={e}, 耗时={duration:.1f}ms")
+        raise HTTPException(
+            status_code=500, 
+            detail=f"更新机器人名字失败: {str(e)}(耗时: {duration:.1f}ms)"
+        )
 
 
 
 
 @app.websocket("/ws")
 @app.websocket("/ws")

+ 50 - 4
backend/services/user_service.py

@@ -202,21 +202,67 @@ class UserService:
         Returns:
         Returns:
             是否更新成功
             是否更新成功
         """
         """
+        import time
+        start_time = time.time()
+        
         try:
         try:
             async with db_manager.get_session() as session:
             async with db_manager.get_session() as session:
+                # 🔥 先查询当前值,避免不必要的更新
+                query_start = time.time()
                 result = await session.execute(
                 result = await session.execute(
+                    select(User.bot_name).where(User.user_id == user_id)
+                )
+                current_bot_name = result.scalar_one_or_none()
+                query_duration = (time.time() - query_start) * 1000
+                
+                # 如果值相同,直接返回(避免不必要的 UPDATE)
+                if current_bot_name == bot_name:
+                    total_duration = (time.time() - start_time) * 1000
+                    logger.info(
+                        f"⏭️ 机器人名字未变化,跳过更新: user_id={user_id}, "
+                        f"bot_name={bot_name}, 耗时={total_duration:.1f}ms"
+                    )
+                    return True
+                
+                # 执行更新
+                update_start = time.time()
+                update_result = await session.execute(
                     update(User)
                     update(User)
                     .where(User.user_id == user_id)
                     .where(User.user_id == user_id)
                     .values(bot_name=bot_name)
                     .values(bot_name=bot_name)
                 )
                 )
                 await session.commit()
                 await session.commit()
+                update_duration = (time.time() - update_start) * 1000
+                total_duration = (time.time() - start_time) * 1000
                 
                 
-                if result.rowcount > 0:
-                    logger.info(f"✅ 更新机器人名字成功: user_id={user_id}, bot_name={bot_name}")
+                if update_result.rowcount > 0:
+                    logger.info(
+                        f"✅ 更新机器人名字成功: user_id={user_id}, "
+                        f"bot_name={bot_name}, "
+                        f"查询耗时={query_duration:.1f}ms, "
+                        f"更新耗时={update_duration:.1f}ms, "
+                        f"总耗时={total_duration:.1f}ms"
+                    )
                     return True
                     return True
-                return False
+                else:
+                    logger.warning(
+                        f"⚠️ 更新机器人名字失败: 未找到用户 user_id={user_id}, "
+                        f"总耗时={total_duration:.1f}ms"
+                    )
+                    return False
+        except asyncio.TimeoutError:
+            total_duration = (time.time() - start_time) * 1000
+            logger.error(
+                f"❌ 更新机器人名字超时: user_id={user_id}, "
+                f"耗时={total_duration:.1f}ms"
+            )
+            return False
         except Exception as e:
         except Exception as e:
-            logger.error(f"❌ 更新机器人名字失败: user_id={user_id}, error={e}")
+            total_duration = (time.time() - start_time) * 1000
+            logger.error(
+                f"❌ 更新机器人名字失败: user_id={user_id}, "
+                f"error={e}, 耗时={total_duration:.1f}ms"
+            )
             return False
             return False
     
     
     async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[int]:
     async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[int]:

+ 1 - 0
frontend/.env.production

@@ -13,6 +13,7 @@ VITE_SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
 
 
 VITE_ENABLE_HTTPS=true
 VITE_ENABLE_HTTPS=true
 
 
+VITE_WELCOME_VIDEO_URL=https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4
 
 
 # SSL证书路径(可选,打包时不需要)
 # SSL证书路径(可选,打包时不需要)
 # VITE_SSL_KEYFILE=/path/to/production/key.pem
 # VITE_SSL_KEYFILE=/path/to/production/key.pem

+ 1 - 0
frontend/ENV_CONFIG.md

@@ -23,6 +23,7 @@ cp .env.example .env
 | `VITE_WS_URL` | WebSocket URL | `wss://localhost:8000/ws` | 否 |
 | `VITE_WS_URL` | WebSocket URL | `wss://localhost:8000/ws` | 否 |
 | `VITE_API_BASE_URL` | API 基础 URL | - | 否 |
 | `VITE_API_BASE_URL` | API 基础 URL | - | 否 |
 | `VITE_APP_BASE_PATH` | 前端部署子路径(以 `/` 开头和结尾) | `/` | 否 |
 | `VITE_APP_BASE_PATH` | 前端部署子路径(以 `/` 开头和结尾) | `/` | 否 |
+| `VITE_WELCOME_VIDEO_URL` | Welcome 页面视频 URL | `https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4` | 否 |
 
 
 ## 使用场景
 ## 使用场景
 
 

+ 47 - 22
frontend/src/main.tsx

@@ -8,23 +8,45 @@ import './index.css';
 // 全局错误处理
 // 全局错误处理
 window.addEventListener('error', (event) => {
 window.addEventListener('error', (event) => {
   console.error('🚨 全局错误:', event.error);
   console.error('🚨 全局错误:', event.error);
-  // 在页面上显示错误信息(开发环境)
-  if (import.meta.env.DEV) {
-    const root = document.getElementById('root');
-    if (root) {
-      root.innerHTML = `
-        <div style="padding: 20px; color: red;">
-          <h2>❌ 应用加载失败</h2>
-          <pre>${event.error?.stack || event.message}</pre>
-          <p>请查看浏览器控制台获取更多信息</p>
-        </div>
-      `;
-    }
+  
+  // 如果错误页面已经加载,不再处理(避免循环)
+  if (window.location.pathname === '/error') {
+    return;
   }
   }
+  
+  const errorMessage = event.error?.message || event.message || '应用加载失败';
+  const errorStack = event.error?.stack || '';
+  
+  // 跳转到错误页面
+  const params = new URLSearchParams({
+    message: `${errorMessage}${errorStack ? `\n\n${errorStack}` : ''}`,
+    code: 'RUNTIME_ERROR',
+    retry: 'true',
+    retryPath: window.location.pathname || '/',
+  });
+  
+  window.location.href = `/error?${params.toString()}`;
 });
 });
 
 
 window.addEventListener('unhandledrejection', (event) => {
 window.addEventListener('unhandledrejection', (event) => {
   console.error('🚨 未处理的 Promise 拒绝:', event.reason);
   console.error('🚨 未处理的 Promise 拒绝:', event.reason);
+  
+  // 如果错误页面已经加载,不再处理(避免循环)
+  if (window.location.pathname === '/error') {
+    return;
+  }
+  
+  const errorMessage = event.reason?.message || String(event.reason) || '未处理的错误';
+  
+  // 跳转到错误页面
+  const params = new URLSearchParams({
+    message: errorMessage,
+    code: 'PROMISE_REJECTION',
+    retry: 'true',
+    retryPath: window.location.pathname || '/',
+  });
+  
+  window.location.href = `/error?${params.toString()}`;
 });
 });
 
 
 // 初始化 rem 适配(必须在渲染前执行)
 // 初始化 rem 适配(必须在渲染前执行)
@@ -49,15 +71,18 @@ try {
   );
   );
 } catch (error) {
 } catch (error) {
   console.error('❌ React 应用渲染失败:', error);
   console.error('❌ React 应用渲染失败:', error);
-  const root = document.getElementById('root');
-  if (root) {
-    root.innerHTML = `
-      <div style="padding: 20px; color: red;">
-        <h2>❌ 应用初始化失败</h2>
-        <pre>${error instanceof Error ? error.stack : String(error)}</pre>
-        <p>请查看浏览器控制台获取更多信息</p>
-      </div>
-    `;
-  }
+  
+  const errorMessage = error instanceof Error ? error.message : String(error);
+  const errorStack = error instanceof Error ? error.stack : '';
+  
+  // 跳转到错误页面
+  const params = new URLSearchParams({
+    message: `${errorMessage}${errorStack ? `\n\n${errorStack}` : ''}`,
+    code: 'RENDER_ERROR',
+    retry: 'true',
+    retryPath: '/',
+  });
+  
+  window.location.href = `/error?${params.toString()}`;
 }
 }
 
 

+ 81 - 0
frontend/src/pages/ErrorPage.tsx

@@ -0,0 +1,81 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { px2rem } from '@/utils/rem';
+import lumiImage from '@/assets/Lumi-image.png';
+import './WelcomePage.css';
+
+/**
+ * 错误页面组件
+ * 显示友好的错误信息,并提供返回选项
+ */
+export const ErrorPage: React.FC = () => {
+  const navigate = useNavigate();
+  
+  // 跳转到欢迎页面
+  const handleRefresh = () => {
+    navigate('/welcome');
+  };
+
+  return (
+    <div className="mobile-container fixed top-0 left-0 animate-fade-in">
+      <div className="content-card safe-area-inset relative flex flex-col h-screen overflow-hidden">
+        {/* 内容容器 - 与 WelcomePage 的 welcome-content 类似 */}
+        <div className="welcome-content" style={{ position: 'relative', width: '100%', minHeight: 'calc(var(--vh, 1vh) * 100)' }}>
+          {/* 错误文字和按钮容器 - 位置与 hello 图片一致 */}
+          <div
+            style={{
+              position: 'absolute',
+              top: '19%',
+              left: '12%',
+              width: '65.33%',
+            }}
+          >
+            {/* 错误文字 */}
+            <h1
+              className="font-medium text-white"
+              style={{
+                fontSize: px2rem(31),
+                lineHeight: px2rem(50),
+                whiteSpace: 'normal',
+                wordBreak: 'keep-all',
+                margin: 0,
+                marginBottom: px2rem(20),
+              }}
+            >
+              出错啦,请刷新尝试
+            </h1>
+
+            {/* 刷新尝试按钮 - 在文字下方 */}
+            <button
+              type="button"
+              onClick={handleRefresh}
+              className="relative text-white font-semibold tracking-[0.5px] transition-all touch-manipulation"
+              style={{
+                width: px2rem(163),
+                height: px2rem(56),
+                borderRadius: px2rem(100),
+                backgroundColor: 'transparent',
+                border: 'none',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                marginTop: px2rem(20),
+              }}
+              aria-label="点击刷新尝试"
+            >
+              → 点击刷新尝试
+            </button>
+          </div>
+        </div>
+        
+        {/* Lumi 图片 - 左下角,与 WelcomePage 样式一致 */}
+        <img 
+          src={lumiImage} 
+          alt="Lumi"
+          className="welcome-lumi"
+        />
+      </div>
+    </div>
+  );
+};
+

+ 13 - 16
frontend/src/pages/RootRedirect.tsx

@@ -1,7 +1,8 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
-import { getNextUnvisitedRoute, hasUserId, setUserId } from '@/utils/storage';
+import { getNextUnvisitedRoute, hasUserId, setUserId, setUserName } from '@/utils/storage';
 import { createUser } from '@/utils/api';
 import { createUser } from '@/utils/api';
+import { handleApiError } from '@/utils/errorHandler';
 
 
 /**
 /**
  * 根路由重定向组件
  * 根路由重定向组件
@@ -10,7 +11,6 @@ import { createUser } from '@/utils/api';
  */
  */
 export const RootRedirect = () => {
 export const RootRedirect = () => {
   const navigate = useNavigate();
   const navigate = useNavigate();
-  const [error, setError] = useState<string | null>(null);
 
 
   useEffect(() => {
   useEffect(() => {
     const handleRedirect = async () => {
     const handleRedirect = async () => {
@@ -35,6 +35,12 @@ export const RootRedirect = () => {
             // 保存用户ID到 localStorage
             // 保存用户ID到 localStorage
             setUserId(response.data.user_id);
             setUserId(response.data.user_id);
             console.log('[RootRedirect] 用户ID已保存:', response.data.user_id);
             console.log('[RootRedirect] 用户ID已保存:', response.data.user_id);
+            
+            // 保存用户名到 localStorage
+            if (response.data?.username) {
+              setUserName(response.data.username);
+              console.log('[RootRedirect] 用户名已保存:', response.data.username);
+            }
           } else {
           } else {
             throw new Error('创建用户失败:响应数据无效');
             throw new Error('创建用户失败:响应数据无效');
           }
           }
@@ -55,25 +61,16 @@ export const RootRedirect = () => {
         }
         }
       } catch (err) {
       } catch (err) {
         console.error('[RootRedirect] 错误:', err);
         console.error('[RootRedirect] 错误:', err);
-        setError(err instanceof Error ? err.message : '未知错误');
+        // 跳转到错误页面,允许重试(重试路径为当前路径)
+        const error = err instanceof Error ? err : new Error('未知错误');
+        handleApiError(error, '/');
       }
       }
     };
     };
 
 
     handleRedirect();
     handleRedirect();
   }, [navigate]);
   }, [navigate]);
 
 
-  // 显示加载状态或错误信息
-  if (error) {
-    return (
-      <div className="w-full h-full flex items-center justify-center bg-white overflow-hidden">
-        <div className="text-center">
-          <div className="text-red-500 mb-2">❌ 错误</div>
-          <div className="text-gray-600 text-sm">{error}</div>
-        </div>
-      </div>
-    );
-  }
-
+  // 显示加载状态
   return (
   return (
     <div className="w-full h-full flex items-center justify-center bg-white overflow-hidden">
     <div className="w-full h-full flex items-center justify-center bg-white overflow-hidden">
       <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
       <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>

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

@@ -18,6 +18,12 @@ export const WelcomePage = () => {
   const playerRef = useRef<VideoJsPlayer | null>(null);
   const playerRef = useRef<VideoJsPlayer | null>(null);
   const [viewState, setViewState] = useState<WelcomeViewState>('welcome');
   const [viewState, setViewState] = useState<WelcomeViewState>('welcome');
   const [isLoading, setIsLoading] = useState(false);
   const [isLoading, setIsLoading] = useState(false);
+  const isVideoReadyRef = useRef(false); // 视频是否已加载完成
+  const loadingStartTimeRef = useRef<number | null>(null); // 加载开始时间
+  const userInteractionRef = useRef(false); // 用户是否已交互(用于确保声音播放)
+  const viewStateRef = useRef<WelcomeViewState>('welcome'); // 用于在回调中访问最新状态
+  const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); // 检查定时器
+  const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 兜底定时器
 
 
   useEffect(() => {
   useEffect(() => {
     // 记录用户当前所在页面
     // 记录用户当前所在页面
@@ -26,6 +32,16 @@ export const WelcomePage = () => {
     // 记录路由访问
     // 记录路由访问
     markRouteVisited('/welcome');
     markRouteVisited('/welcome');
 
 
+    // 🚀 提前预加载视频资源(使用 link preload)
+    const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || 'https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4';
+    const preloadLink = document.createElement('link');
+    preloadLink.rel = 'preload';
+    preloadLink.as = 'video';
+    preloadLink.href = videoUrl;
+    preloadLink.crossOrigin = 'anonymous';
+    document.head.appendChild(preloadLink);
+    console.log('[WelcomePage] 已添加视频预加载链接:', videoUrl);
+
     // 移动端视口高度修复:处理浏览器地址栏/工具栏导致的视口高度变化
     // 移动端视口高度修复:处理浏览器地址栏/工具栏导致的视口高度变化
     const setViewportHeight = () => {
     const setViewportHeight = () => {
       // 获取实际可视区域高度(不包括浏览器UI)
       // 获取实际可视区域高度(不包括浏览器UI)
@@ -44,6 +60,10 @@ export const WelcomePage = () => {
     return () => {
     return () => {
       window.removeEventListener('resize', setViewportHeight);
       window.removeEventListener('resize', setViewportHeight);
       window.removeEventListener('orientationchange', setViewportHeight);
       window.removeEventListener('orientationchange', setViewportHeight);
+      // 清理预加载链接
+      if (preloadLink.parentNode) {
+        preloadLink.parentNode.removeChild(preloadLink);
+      }
     };
     };
   }, []);
   }, []);
 
 
@@ -88,6 +108,57 @@ export const WelcomePage = () => {
           videoElement.style.opacity = '0';
           videoElement.style.opacity = '0';
           videoElement.style.position = 'absolute';
           videoElement.style.position = 'absolute';
           videoElement.style.top = '-9999px';
           videoElement.style.top = '-9999px';
+
+          // 🚀 强制开始加载视频(立即开始预加载)
+          try {
+            videoElement.load(); // 强制开始加载
+            console.log('[WelcomePage] 已强制开始加载视频');
+          } catch (error) {
+            console.warn('[WelcomePage] 强制加载失败(可能已开始加载):', error);
+          }
+
+          // 监听视频加载状态
+          const handleCanPlayThrough = () => {
+            console.log('[WelcomePage] 视频已加载完成,可以播放');
+            isVideoReadyRef.current = true;
+            // 如果正在加载状态,检查是否可以开始播放
+            if (viewStateRef.current === 'loading' && loadingStartTimeRef.current) {
+              checkAndStartPlayback();
+            }
+          };
+
+          const handleError = (error: Event) => {
+            console.error('[WelcomePage] 视频加载错误:', error);
+            const videoEl = error.target as HTMLVideoElement;
+            if (videoEl && videoEl.error) {
+              console.error('[WelcomePage] 视频错误详情:', {
+                code: videoEl.error?.code,
+                message: videoEl.error?.message,
+              });
+            }
+            // 即使出错也尝试继续(可能是网络问题,稍后重试)
+            isVideoReadyRef.current = false;
+          };
+
+          const handleLoadedData = () => {
+            console.log('[WelcomePage] 视频数据已加载');
+          };
+
+          const handleLoadedMetadata = () => {
+            console.log('[WelcomePage] 视频元数据已加载');
+          };
+
+          // 绑定事件
+          videoElement.addEventListener('canplaythrough', handleCanPlayThrough);
+          videoElement.addEventListener('error', handleError);
+          videoElement.addEventListener('loadeddata', handleLoadedData);
+          videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
+
+          // 保存事件处理器以便清理
+          (player as any)._canPlayThroughHandler = handleCanPlayThrough;
+          (player as any)._errorHandler = handleError;
+          (player as any)._loadedDataHandler = handleLoadedData;
+          (player as any)._loadedMetadataHandler = handleLoadedMetadata;
         }
         }
 
 
         // 持续监控并移除 controls 属性
         // 持续监控并移除 controls 属性
@@ -126,10 +197,119 @@ export const WelcomePage = () => {
         if (endedHandler) {
         if (endedHandler) {
           player.off('ended', endedHandler);
           player.off('ended', endedHandler);
         }
         }
+        // 清理视频元素事件监听器
+        const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
+        if (videoElement) {
+          const canPlayThroughHandler = (player as any)._canPlayThroughHandler;
+          const errorHandler = (player as any)._errorHandler;
+          const loadedDataHandler = (player as any)._loadedDataHandler;
+          const loadedMetadataHandler = (player as any)._loadedMetadataHandler;
+          const videoEndedHandler = (player as any)._videoEndedHandler;
+          if (canPlayThroughHandler) {
+            videoElement.removeEventListener('canplaythrough', canPlayThroughHandler);
+          }
+          if (errorHandler) {
+            videoElement.removeEventListener('error', errorHandler);
+          }
+          if (loadedDataHandler) {
+            videoElement.removeEventListener('loadeddata', loadedDataHandler);
+          }
+          if (loadedMetadataHandler) {
+            videoElement.removeEventListener('loadedmetadata', loadedMetadataHandler);
+          }
+          if (videoEndedHandler) {
+            videoElement.removeEventListener('ended', videoEndedHandler);
+          }
+        }
         // 注意:不在这里销毁播放器,因为视频需要保持加载状态
         // 注意:不在这里销毁播放器,因为视频需要保持加载状态
       }
       }
     };
     };
-  }, [navigate]);
+  }, [navigate, viewState]);
+
+  // 更新 viewState 时同步更新 ref
+  useEffect(() => {
+    viewStateRef.current = viewState;
+  }, [viewState]);
+
+  // 组件卸载时清理定时器
+  useEffect(() => {
+    return () => {
+      if (checkIntervalRef.current) {
+        clearInterval(checkIntervalRef.current);
+        checkIntervalRef.current = null;
+      }
+      if (fallbackTimerRef.current) {
+        clearTimeout(fallbackTimerRef.current);
+        fallbackTimerRef.current = null;
+      }
+    };
+  }, []);
+
+  // 检查并开始播放(结合加载状态和最小缓冲时间)
+  const checkAndStartPlayback = () => {
+    if (!playerRef.current || viewStateRef.current !== 'loading') {
+      return;
+    }
+
+    const now = Date.now();
+    const minLoadingTime = 3000; // 最小加载时间3秒
+    const elapsed = loadingStartTimeRef.current 
+      ? now - loadingStartTimeRef.current 
+      : 0;
+    const remainingTime = Math.max(0, minLoadingTime - elapsed);
+
+    // 如果视频已准备好且达到最小加载时间,开始播放
+    if (isVideoReadyRef.current && elapsed >= minLoadingTime) {
+      console.log('[WelcomePage] 视频已准备好,开始播放');
+      // 清理定时器
+      if (checkIntervalRef.current) {
+        clearInterval(checkIntervalRef.current);
+        checkIntervalRef.current = null;
+      }
+      if (fallbackTimerRef.current) {
+        clearTimeout(fallbackTimerRef.current);
+        fallbackTimerRef.current = null;
+      }
+      setIsLoading(false);
+      setViewState('playing');
+    } else if (isVideoReadyRef.current && remainingTime > 0) {
+      // 视频已准备好,但还需要等待最小加载时间
+      console.log(`[WelcomePage] 视频已准备好,等待 ${remainingTime}ms 后播放`);
+      // 清理检查定时器
+      if (checkIntervalRef.current) {
+        clearInterval(checkIntervalRef.current);
+        checkIntervalRef.current = null;
+      }
+      setTimeout(() => {
+        if (viewStateRef.current === 'loading') {
+          setIsLoading(false);
+          setViewState('playing');
+        }
+      }, remainingTime);
+    } else if (!isVideoReadyRef.current && elapsed >= minLoadingTime) {
+      // 达到最小加载时间但视频未准备好,再等待一段时间
+      console.log('[WelcomePage] 达到最小加载时间,但视频未完全准备好,继续等待...');
+      // 清理检查定时器
+      if (checkIntervalRef.current) {
+        clearInterval(checkIntervalRef.current);
+        checkIntervalRef.current = null;
+      }
+      const waitTimer = setTimeout(() => {
+        if (viewStateRef.current === 'loading') {
+          if (isVideoReadyRef.current) {
+            setIsLoading(false);
+            setViewState('playing');
+          } else {
+            // 即使未完全准备好也尝试播放(可能是网络慢)
+            console.warn('[WelcomePage] 视频未完全准备好,尝试播放');
+            setIsLoading(false);
+            setViewState('playing');
+          }
+        }
+      }, 2000); // 额外等待2秒
+      (playerRef.current as any)._waitTimer = waitTimer;
+    }
+  };
 
 
   // 当进入播放状态时,显示视频并开始播放
   // 当进入播放状态时,显示视频并开始播放
   useEffect(() => {
   useEffect(() => {
@@ -152,29 +332,79 @@ export const WelcomePage = () => {
         videoElement.style.height = '100%';
         videoElement.style.height = '100%';
         videoElement.style.zIndex = '2';
         videoElement.style.zIndex = '2';
 
 
-        // 确保视频未静音
+        // 绑定视频播放结束事件(原生事件,确保可靠)
+        const handleVideoEnded = () => {
+          console.log('[WelcomePage] 视频播放完成,跳转到 /naming');
+          navigate('/naming');
+        };
+        videoElement.addEventListener('ended', handleVideoEnded);
+        // 保存处理器以便清理
+        (player as any)._videoEndedHandler = handleVideoEnded;
+
+        // 确保视频未静音(用户已交互,应该可以播放声音)
         player.muted(false);
         player.muted(false);
         videoElement.muted = false;
         videoElement.muted = false;
         videoElement.volume = 1.0;
         videoElement.volume = 1.0;
         
         
-        // 尝试播放
+        // 由于用户已点击(交互),移动端Chrome应该允许播放声音
+        // 尝试播放(有声)
         const playPromise = player.play();
         const playPromise = player.play();
         if (playPromise) {
         if (playPromise) {
           playPromise
           playPromise
+            .then(() => {
+              console.log('[WelcomePage] 视频播放成功(有声)');
+              // 确保未静音
+              player.muted(false);
+              videoElement.muted = false;
+              videoElement.volume = 1.0;
+            })
             .catch((error: unknown) => {
             .catch((error: unknown) => {
-              console.warn('[WelcomePage] 自动播放失败,尝试静音播放:', error);
+              console.warn('[WelcomePage] 有声播放失败,尝试静音播放:', error);
+              // 如果有声播放失败,尝试静音播放
               player.muted(true);
               player.muted(true);
+              videoElement.muted = true;
               const mutedPlayPromise = player.play();
               const mutedPlayPromise = player.play();
               if (mutedPlayPromise) {
               if (mutedPlayPromise) {
-                mutedPlayPromise.catch((err: unknown) => {
-                  console.error('[WelcomePage] 静音播放也失败:', err);
-                });
+                mutedPlayPromise
+                  .then(() => {
+                    console.log('[WelcomePage] 静音播放成功,尝试恢复声音');
+                    // 静音播放成功后,尝试恢复声音(需要用户再次交互)
+                    // 但由于用户已点击,应该可以恢复
+                    setTimeout(() => {
+                      try {
+                        player.muted(false);
+                        videoElement.muted = false;
+                        videoElement.volume = 1.0;
+                        console.log('[WelcomePage] 已尝试恢复声音');
+                      } catch (e) {
+                        console.warn('[WelcomePage] 恢复声音失败:', e);
+                      }
+                    }, 100);
+                  })
+                  .catch((err: unknown) => {
+                    console.error('[WelcomePage] 静音播放也失败:', err);
+                  });
               }
               }
             });
             });
         }
         }
       }
       }
+
+      // 清理函数:移除事件监听器
+      return () => {
+        if (playerRef.current) {
+          const player = playerRef.current;
+          const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
+          if (videoElement) {
+            const videoEndedHandler = (player as any)._videoEndedHandler;
+            if (videoEndedHandler) {
+              videoElement.removeEventListener('ended', videoEndedHandler);
+              (player as any)._videoEndedHandler = null;
+            }
+          }
+        }
+      };
     }
     }
-  }, [viewState]);
+  }, [viewState, navigate]);
 
 
   // 监听播放结束事件(备用方案,确保事件已绑定)
   // 监听播放结束事件(备用方案,确保事件已绑定)
   useEffect(() => {
   useEffect(() => {
@@ -193,16 +423,90 @@ export const WelcomePage = () => {
     }
     }
   }, [navigate, viewState]); // 当播放状态改变时,确保事件已绑定
   }, [navigate, viewState]); // 当播放状态改变时,确保事件已绑定
 
 
-  const handleHelloClick = () => {
+  const handleHelloClick = async () => {
+    // 标记用户已交互(用于确保声音播放)
+    userInteractionRef.current = true;
+    
+    // 清理之前的定时器
+    if (checkIntervalRef.current) {
+      clearInterval(checkIntervalRef.current);
+      checkIntervalRef.current = null;
+    }
+    if (fallbackTimerRef.current) {
+      clearTimeout(fallbackTimerRef.current);
+      fallbackTimerRef.current = null;
+    }
+    
     // 切换到加载状态
     // 切换到加载状态
     setViewState('loading');
     setViewState('loading');
     setIsLoading(true);
     setIsLoading(true);
+    loadingStartTimeRef.current = Date.now();
 
 
-    // 3秒加载倒计时
-    setTimeout(() => {
-      setIsLoading(false);
-      setViewState('playing');
-    }, 3000);
+    // 🔑 关键修复:在用户交互的直接调用链中立即尝试播放
+    // 这样可以"锁定"用户交互权限,即使视频还没准备好
+    if (playerRef.current) {
+      const player = playerRef.current;
+      const videoElement = player.el()?.querySelector('video') as HTMLVideoElement;
+      
+      if (videoElement) {
+        // 确保未静音
+        player.muted(false);
+        videoElement.muted = false;
+        videoElement.volume = 1.0;
+        
+        // 立即尝试播放(在用户交互的直接调用链中)
+        // 如果视频还没准备好,这会失败,但没关系,我们会在视频准备好后再播放
+        try {
+          const playPromise = player.play();
+          if (playPromise) {
+            await playPromise;
+            console.log('[WelcomePage] ✅ 在用户交互中立即播放成功');
+            // 如果立即播放成功,直接进入播放状态
+            setIsLoading(false);
+            setViewState('playing');
+            return; // 成功播放,直接返回
+          }
+        } catch (error) {
+          console.log('[WelcomePage] 视频未准备好,等待加载完成后再播放:', error);
+          // 播放失败是预期的(视频可能还没准备好),继续等待加载
+        }
+      }
+    }
+
+    // 如果立即播放失败(视频未准备好),使用原来的逻辑等待加载
+    // 检查视频是否已准备好
+    if (isVideoReadyRef.current) {
+      // 视频已准备好,使用最小加载时间
+      setTimeout(() => {
+        checkAndStartPlayback();
+      }, 0);
+    } else {
+      // 视频未准备好,等待加载完成或达到最小时间
+      // 设置一个兜底定时器(最多等待5秒)
+      fallbackTimerRef.current = setTimeout(() => {
+        if (viewStateRef.current === 'loading') {
+          console.warn('[WelcomePage] 加载超时,强制开始播放');
+          if (checkIntervalRef.current) {
+            clearInterval(checkIntervalRef.current);
+            checkIntervalRef.current = null;
+          }
+          setIsLoading(false);
+          setViewState('playing');
+        }
+      }, 5000);
+      
+      // 定期检查是否可以开始播放
+      checkIntervalRef.current = setInterval(() => {
+        if (viewStateRef.current === 'loading') {
+          checkAndStartPlayback();
+        } else {
+          if (checkIntervalRef.current) {
+            clearInterval(checkIntervalRef.current);
+            checkIntervalRef.current = null;
+          }
+        }
+      }, 100);
+    }
   };
   };
 
 
   return (
   return (
@@ -259,8 +563,12 @@ export const WelcomePage = () => {
           data-setup="{}"
           data-setup="{}"
           controlsList="nodownload nofullscreen noremoteplayback"
           controlsList="nodownload nofullscreen noremoteplayback"
           disablePictureInPicture
           disablePictureInPicture
+          preload="auto"
         >
         >
-          <source src="https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4" type="video/mp4" />
+          <source 
+            src={import.meta.env.VITE_WELCOME_VIDEO_URL || 'https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4'} 
+            type="video/mp4" 
+          />
           <p className="vjs-no-js">
           <p className="vjs-no-js">
             您的浏览器不支持HTML5视频,请升级浏览器。
             您的浏览器不支持HTML5视频,请升级浏览器。
           </p>
           </p>

+ 5 - 0
frontend/src/router/index.tsx

@@ -11,6 +11,7 @@ import { ScreenSizeTestPage } from '@/pages/ScreenSizeTestPage';
 import { DebugPage } from '@/pages/DebugPage';
 import { DebugPage } from '@/pages/DebugPage';
 import { SimpleTestPage } from '@/pages/SimpleTestPage';
 import { SimpleTestPage } from '@/pages/SimpleTestPage';
 import { ChatBotDebugPage } from '@/pages/ChatBotDebugPage';
 import { ChatBotDebugPage } from '@/pages/ChatBotDebugPage';
+import { ErrorPage } from '@/pages/ErrorPage';
 import { UserGuard } from '@/components/UserGuard';
 import { UserGuard } from '@/components/UserGuard';
 
 
 const basename = import.meta.env.BASE_URL || '/';
 const basename = import.meta.env.BASE_URL || '/';
@@ -72,6 +73,10 @@ export const router = createBrowserRouter([
         path: '/chatbot-debug',
         path: '/chatbot-debug',
         element: <ChatBotDebugPage />,
         element: <ChatBotDebugPage />,
       },
       },
+      {
+        path: '/error',
+        element: <ErrorPage />,
+      },
       {
       {
         path: '*',
         path: '*',
         element: <Navigate to="/" replace />,
         element: <Navigate to="/" replace />,

+ 95 - 0
frontend/src/utils/errorHandler.ts

@@ -0,0 +1,95 @@
+/**
+ * 错误处理工具函数
+ * 统一管理错误处理和错误页面跳转
+ */
+
+/**
+ * 跳转到错误页面
+ * @param error 错误对象或错误消息
+ * @param options 错误页面选项
+ */
+export const navigateToErrorPage = (
+  error: Error | string,
+  options?: {
+    code?: string;
+    canRetry?: boolean;
+    retryPath?: string;
+  }
+) => {
+  const errorMessage = error instanceof Error ? error.message : error;
+  const errorCode = options?.code || 'UNKNOWN';
+  const canRetry = options?.canRetry ?? false;
+  const retryPath = options?.retryPath || '/';
+
+  // 构建错误页面的 URL 参数
+  const params = new URLSearchParams({
+    message: errorMessage,
+    code: errorCode,
+    retry: canRetry.toString(),
+    retryPath,
+  });
+
+  // 使用 window.location 跳转,确保完全刷新
+  window.location.href = `/error?${params.toString()}`;
+};
+
+/**
+ * 根据错误类型获取错误代码
+ */
+export const getErrorCode = (error: Error | string): string => {
+  const errorMessage = error instanceof Error ? error.message : error;
+  
+  if (errorMessage.includes('网络') || errorMessage.includes('fetch') || errorMessage.includes('ECONNREFUSED')) {
+    return 'NETWORK_ERROR';
+  }
+  
+  if (errorMessage.includes('超时') || errorMessage.includes('timeout')) {
+    return 'TIMEOUT_ERROR';
+  }
+  
+  if (errorMessage.includes('500') || errorMessage.includes('Internal Server Error')) {
+    return 'SERVER_ERROR';
+  }
+  
+  if (errorMessage.includes('404') || errorMessage.includes('Not Found')) {
+    return 'NOT_FOUND';
+  }
+  
+  if (errorMessage.includes('401') || errorMessage.includes('Unauthorized')) {
+    return 'UNAUTHORIZED';
+  }
+  
+  if (errorMessage.includes('403') || errorMessage.includes('Forbidden')) {
+    return 'FORBIDDEN';
+  }
+  
+  return 'UNKNOWN_ERROR';
+};
+
+/**
+ * 判断错误是否可重试
+ */
+export const isRetryableError = (error: Error | string): boolean => {
+  const errorCode = getErrorCode(error);
+  
+  // 网络错误、超时错误、服务器错误可以重试
+  return ['NETWORK_ERROR', 'TIMEOUT_ERROR', 'SERVER_ERROR'].includes(errorCode);
+};
+
+/**
+ * 处理 API 错误并跳转到错误页面
+ */
+export const handleApiError = (
+  error: Error | string,
+  retryPath?: string
+) => {
+  const errorCode = getErrorCode(error);
+  const canRetry = isRetryableError(error);
+  
+  navigateToErrorPage(error, {
+    code: errorCode,
+    canRetry,
+    retryPath: retryPath || '/',
+  });
+};
+

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

@@ -190,6 +190,27 @@ export const hasUserId = (): boolean => {
   return getUserId() !== null;
   return getUserId() !== null;
 };
 };
 
 
+/**
+ * 获取用户名
+ */
+export const getUserName = (): string | null => {
+  return getStorage(STORAGE_KEYS.USER_NAME);
+};
+
+/**
+ * 设置用户名
+ */
+export const setUserName = (user_name: string): boolean => {
+  return setStorage(STORAGE_KEYS.USER_NAME, user_name);
+};
+
+/**
+ * 清除用户名
+ */
+export const clearUserName = (): boolean => {
+  return removeStorage(STORAGE_KEYS.USER_NAME);
+};
+
 /**
 /**
  * 清除用户相关的路由和页面状态
  * 清除用户相关的路由和页面状态
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page

+ 1 - 0
frontend/src/vite-env.d.ts

@@ -2,6 +2,7 @@
 
 
 interface ImportMetaEnv {
 interface ImportMetaEnv {
   readonly VITE_WS_URL?: string;
   readonly VITE_WS_URL?: string;
+  readonly VITE_WELCOME_VIDEO_URL?: string;
 }
 }
 
 
 interface ImportMeta {
 interface ImportMeta {