浏览代码

提交代码

zlwu 10 月之前
父节点
当前提交
249ecf56b9

+ 41 - 21
backend/main.py

@@ -116,12 +116,14 @@ class RegisterPhoneRequest(BaseModel):
     """手机号注册请求"""
     phone: str
     username: str = ""
+    user_id: Optional[str] = None  # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
 
 
 class RegisterEmailRequest(BaseModel):
     """邮箱注册请求"""
     email: str
     username: str = ""
+    user_id: Optional[str] = None  # 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
 
 
 class LoginWechatRequest(BaseModel):
@@ -148,6 +150,7 @@ class LoginEmailRequest(BaseModel):
 
 class SendCodeRequest(BaseModel):
     """发送验证码请求"""
+    area_code: Optional[str] = None  # 手机区号(如 +86)
     phone: Optional[str] = None
     email: Optional[str] = None
     send_type: str  # "domestic_sms", "international_sms", "email"
@@ -397,8 +400,8 @@ async def get_user_background_images(user_id: str):
 async def get_avatar_gifs() -> Dict[str, Any]:
     """
     获取头像 GIF 配置
-    - 优先读取数据库 avatar_gifs 表(is_active=1,按 sort_order、id 排序)
-    - 字段期望:code, default_url, positive_url, negative_url, talking_url
+    - 优先读取数据库 avatar_assets 表(按 avatar_id、state 分组)
+    - 字段:avatar_id, state, image_url
     - 失败/无数据时返回静态兜底配置
     """
     try:
@@ -408,31 +411,35 @@ async def get_avatar_gifs() -> Dict[str, Any]:
             result = await session.execute(
                 text(
                     """
-                    SELECT id, code, default_url, positive_url, negative_url, talking_url, is_active
-                    FROM avatar_gifs
-                    WHERE is_active = 1
-                    ORDER BY sort_order ASC, id ASC
+                    SELECT avatar_id, state, image_url
+                    FROM avatar_assets
+                    WHERE state IN ('default', 'positive', 'negative', 'talking')
+                    ORDER BY avatar_id, state
                     """
                 )
             )
             rows = result.mappings().all()
 
         if rows:
+            # 按 avatar_id 分组,转换为期望的格式
             data: Dict[str, Dict[str, str]] = {}
             for row in rows:
-                code = row.get("code") or row.get("id")
-                if not code:
+                avatar_id = row.get("avatar_id")
+                state = row.get("state")
+                image_url = row.get("image_url")
+                
+                if not avatar_id or not state or not image_url:
                     continue
-                data[str(code)] = {
-                    "default": row.get("default_url"),
-                    "positive": row.get("positive_url"),
-                    "negative": row.get("negative_url"),
-                    "talking": row.get("talking_url"),
-                }
+                
+                if avatar_id not in data:
+                    data[avatar_id] = {}
+                
+                data[avatar_id][state] = image_url
+            
             if data:
                 return {"success": True, "data": data}
 
-        logger.warning("avatar_gifs 表无记录,返回兜底静态配置")
+        logger.warning("avatar_assets 表无记录,返回兜底静态配置")
     except Exception as exc:
         logger.error(f"获取头像 GIF 配置失败,使用兜底: {exc}")
 
@@ -587,7 +594,8 @@ async def register_with_phone(request: RegisterPhoneRequest):
     try:
         user = await user_service.register_with_phone(
             phone=request.phone,
-            username=request.username
+            username=request.username,
+            user_id=request.user_id
         )
         return {
             "success": True,
@@ -619,7 +627,8 @@ async def register_with_email(request: RegisterEmailRequest):
     try:
         user = await user_service.register_with_email(
             email=request.email,
-            username=request.username
+            username=request.username,
+            user_id=request.user_id
         )
         return {
             "success": True,
@@ -818,6 +827,7 @@ async def send_code(request: SendCodeRequest):
             # 创建新的验证码
             verification_code = VerificationCode(
                 code_id=code_id,
+                area_code=request.area_code,
                 phone=request.phone,
                 email=request.email,
                 code=code,
@@ -828,7 +838,7 @@ async def send_code(request: SendCodeRequest):
             session.add(verification_code)
             await session.commit()
         
-        logger.info(f"✅ 验证码已生成: code_id={code_id}, send_type={request.send_type}, phone={request.phone}, email={request.email}")
+        logger.info(f"✅ 验证码已生成: code_id={code_id}, send_type={request.send_type}, area_code={request.area_code}, phone={request.phone}, email={request.email}")
         
         return {
             "success": True,
@@ -1325,15 +1335,25 @@ async def qq_callback(
         except Exception as info_error:
             logger.warning(f"拉取QQ用户信息异常: {info_error}")
         
-        # 3. 使用openid登录/注册
+        # 3. 检查用户是否存在
+        user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
+        
+        if not user_exists:
+            # 用户不存在,返回 welcome 页面并提示
+            logger.info(f"⚠️ QQ用户未注册: openid={openid}")
+            return RedirectResponse(
+                url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
+            )
+        
+        # 4. 用户存在,使用openid登录
         user = await user_service.login_with_qq(
             openid=openid,
             username=nickname,
             unionid=unionid,
         )
         
-        # 4. 重定向到前端,携带用户信息
-        frontend_url = f"{settings.FRONTEND_URL}/register?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
+        # 5. 重定向到聊天界面,携带用户信息
+        frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
         return RedirectResponse(url=frontend_url)
         
     except Exception as e:

+ 75 - 0
backend/migrate_add_area_code_to_verification_codes.py

@@ -0,0 +1,75 @@
+"""
+数据库迁移脚本:为 verification_codes 表添加 area_code 字段
+运行: python migrate_add_area_code_to_verification_codes.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_add_area_code_field() -> bool:
+    """为 verification_codes 表添加 area_code 字段"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查字段是否已存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s 
+                AND table_name = 'verification_codes' 
+                AND column_name = 'area_code'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            field_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not field_exists:
+                print("🔄 为 verification_codes 表添加 area_code 字段...")
+                await cursor.execute(
+                    """
+                    ALTER TABLE verification_codes
+                    ADD COLUMN area_code VARCHAR(10) NULL COMMENT '手机区号(如 +86)'
+                    AFTER code_id
+                    """
+                )
+                await conn.commit()
+                print("✅ area_code 字段添加完成")
+            else:
+                print("✅ area_code 字段已存在")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_add_area_code_field())
+    if not ok:
+        sys.exit(1)
+

+ 30 - 0
backend/migrations/recreate_conversation_energy_stones.sql

@@ -0,0 +1,30 @@
+-- 对话能量石记录表重建脚本
+-- 如果表已存在,先删除(谨慎使用,确保已备份数据)
+-- DROP TABLE IF EXISTS conversation_energy_stones;
+
+CREATE TABLE IF NOT EXISTS conversation_energy_stones (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT,
+    stone_id VARCHAR(64) UNIQUE NOT NULL COMMENT '能量石ID',
+    user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
+    request_id VARCHAR(64) NULL COMMENT '触发的请求ID',
+    threshold_index INT NOT NULL COMMENT '达成阈值的序号(1=30秒,2=60秒...)',
+    total_duration_ms BIGINT NOT NULL COMMENT '累计说话时长(毫秒)',
+    duration_increment_ms INT NOT NULL COMMENT '本次新增时长(毫秒)',
+    appear_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '出现时间',
+    clicked_at TIMESTAMP NULL COMMENT '点击时间',
+    auto_removed BOOLEAN DEFAULT FALSE COMMENT '是否自动消失',
+    metadata JSON NULL COMMENT '扩展信息',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    
+    -- 唯一约束:同一用户同一阈值只能有一条记录
+    UNIQUE KEY uk_conversation_stone_threshold (user_id, threshold_index),
+    
+    -- 索引
+    INDEX idx_stone_id (stone_id),
+    INDEX idx_user_id (user_id),
+    INDEX idx_conversation_stone_user_threshold (user_id, threshold_index),
+    INDEX idx_appear_at (appear_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='对话能量石表(按时长发放)';
+

+ 2 - 0
backend/models_db.py

@@ -558,6 +558,7 @@ class VerificationCode(Base):
     code_id = Column(String(64), unique=True, nullable=False, index=True, comment="验证码ID")
     
     # 接收方信息(手机号或邮箱,根据发送类型二选一)
+    area_code = Column(String(10), nullable=True, comment="手机区号(如 +86)")
     phone = Column(String(20), nullable=True, index=True, comment="手机号")
     email = Column(String(200), nullable=True, index=True, comment="邮箱")
     
@@ -590,6 +591,7 @@ class VerificationCode(Base):
         """转换为字典"""
         return {
             "code_id": self.code_id,
+            "area_code": self.area_code,
             "phone": self.phone,
             "email": self.email,
             "code": self.code,

+ 197 - 64
backend/services/user_service.py

@@ -585,121 +585,223 @@ class UserService:
             logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
             return None
     
-    async def register_with_phone(self, phone: str, username: str = "") -> Dict:
+    async def register_with_phone(self, phone: str, username: str = "", user_id: Optional[str] = None) -> Dict:
         """
         使用手机号注册
         
         Args:
             phone: 手机号
             username: 用户名(可选)
+            user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
         
         Returns:
             用户信息字典
         """
         try:
             async with db_manager.get_session() as session:
-                # 检查手机号是否已注册
+                # 检查手机号是否已被其他用户注册
                 result = await session.execute(
                     select(User).where(User.phone == phone)
                 )
-                existing_user = result.scalar_one_or_none()
-                
-                if existing_user:
-                    raise ValueError("该手机号已被注册")
-                
-                # 生成用户ID和用户名
-                user_id = str(uuid.uuid4())
-                if not username or username.strip() == "":
-                    username = await self._generate_temp_username(session)
-                
-                user = User(
-                    user_id=user_id,
-                    username=username,
-                    phone=phone,
-                    user_type="registered",
-                    is_verified=True,
-                    registered_at=datetime.now(),
-                    current_background_image_id="default",  # 设置默认背景图片
-                )
-                session.add(user)
-                await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
-                
-                # 初始化背景图片
-                await self._init_user_background_image(session, user_id)
+                existing_phone_user = result.scalar_one_or_none()
                 
-                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
-                await self._init_user_avatar(session, user_id, user_type="registered")
+                if existing_phone_user:
+                    # 如果提供了 user_id,检查是否是同一个用户
+                    if user_id and existing_phone_user.user_id == user_id:
+                        # 同一个用户,可能是重新注册,允许继续
+                        pass
+                    else:
+                        raise ValueError("该手机号已被注册")
                 
-                await session.commit()
-                await session.refresh(user)
+                # 如果提供了 user_id,使用现有用户
+                final_user_id = None
+                if user_id:
+                    result = await session.execute(
+                        select(User).where(User.user_id == user_id)
+                    )
+                    existing_user = result.scalar_one_or_none()
+                    
+                    if not existing_user:
+                        raise ValueError(f"用户ID {user_id} 不存在")
+                    
+                    if existing_user.user_type == "registered":
+                        raise ValueError("该用户已经注册过了")
+                    
+                    # 更新现有用户
+                    if not username or username.strip() == "":
+                        # 如果用户已有用户名,保留;否则生成新用户名
+                        if not existing_user.username or existing_user.username.strip() == "":
+                            username = await self._generate_temp_username(session)
+                        else:
+                            username = existing_user.username
+                    
+                    existing_user.phone = phone
+                    existing_user.username = username
+                    existing_user.user_type = "registered"
+                    existing_user.is_verified = True
+                    existing_user.registered_at = datetime.now()
+                    
+                    user = existing_user
+                    final_user_id = user_id
+                    await session.flush()
+                    
+                    # 如果用户还没有背景图片,初始化背景图片
+                    if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
+                        await self._init_user_background_image(session, user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
+                else:
+                    # 创建新用户(原有逻辑)
+                    new_user_id = str(uuid.uuid4())
+                    if not username or username.strip() == "":
+                        username = await self._generate_temp_username(session)
+                    
+                    user = User(
+                        user_id=new_user_id,
+                        username=username,
+                        phone=phone,
+                        user_type="registered",
+                        is_verified=True,
+                        registered_at=datetime.now(),
+                        current_background_image_id="default",  # 设置默认背景图片
+                    )
+                    session.add(user)
+                    await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
+                    
+                    final_user_id = new_user_id
+                    
+                    # 初始化背景图片
+                    await self._init_user_background_image(session, new_user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, new_user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
             
             # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            final_user_id = final_user_id if final_user_id else user.user_id
             await self.upsert_user_ui_state(
-                user_id=user_id,
+                user_id=final_user_id,
                 current_avatar_id=REGISTERED_AVATAR_ID
             )
             
-            logger.info(f"✅ 手机号注册成功: user_id={user_id}, phone={phone}")
+            logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, phone={phone}")
             return user.to_dict()
         except Exception as e:
             logger.error(f"❌ 手机号注册失败: {e}")
             raise
     
-    async def register_with_email(self, email: str, username: str = "") -> Dict:
+    async def register_with_email(self, email: str, username: str = "", user_id: Optional[str] = None) -> Dict:
         """
         使用邮箱注册
         
         Args:
             email: 邮箱
             username: 用户名(可选)
+            user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
         
         Returns:
             用户信息字典
         """
         try:
             async with db_manager.get_session() as session:
-                # 检查邮箱是否已注册
+                # 检查邮箱是否已被其他用户注册
                 result = await session.execute(
                     select(User).where(User.email == email)
                 )
-                existing_user = result.scalar_one_or_none()
+                existing_email_user = result.scalar_one_or_none()
                 
-                if existing_user:
-                    raise ValueError("该邮箱已被注册")
+                if existing_email_user:
+                    # 如果提供了 user_id,检查是否是同一个用户
+                    if user_id and existing_email_user.user_id == user_id:
+                        # 同一个用户,可能是重新注册,允许继续
+                        pass
+                    else:
+                        raise ValueError("该邮箱已被注册")
                 
-                # 生成用户ID和用户名
-                user_id = str(uuid.uuid4())
-                if not username or username.strip() == "":
-                    username = await self._generate_temp_username(session)
-                
-                user = User(
-                    user_id=user_id,
-                    username=username,
-                    email=email,
-                    user_type="registered",
-                    is_verified=True,
-                    registered_at=datetime.now(),
-                    current_background_image_id="default",  # 设置默认背景图片
-                )
-                session.add(user)
-                await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
-                
-                # 初始化背景图片
-                await self._init_user_background_image(session, user_id)
-                
-                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
-                await self._init_user_avatar(session, user_id, user_type="registered")
-                
-                await session.commit()
-                await session.refresh(user)
+                # 如果提供了 user_id,使用现有用户
+                final_user_id = None
+                if user_id:
+                    result = await session.execute(
+                        select(User).where(User.user_id == user_id)
+                    )
+                    existing_user = result.scalar_one_or_none()
+                    
+                    if not existing_user:
+                        raise ValueError(f"用户ID {user_id} 不存在")
+                    
+                    if existing_user.user_type == "registered":
+                        raise ValueError("该用户已经注册过了")
+                    
+                    # 更新现有用户
+                    if not username or username.strip() == "":
+                        # 如果用户已有用户名,保留;否则生成新用户名
+                        if not existing_user.username or existing_user.username.strip() == "":
+                            username = await self._generate_temp_username(session)
+                        else:
+                            username = existing_user.username
+                    
+                    existing_user.email = email
+                    existing_user.username = username
+                    existing_user.user_type = "registered"
+                    existing_user.is_verified = True
+                    existing_user.registered_at = datetime.now()
+                    
+                    user = existing_user
+                    final_user_id = user_id
+                    await session.flush()
+                    
+                    # 如果用户还没有背景图片,初始化背景图片
+                    if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
+                        await self._init_user_background_image(session, user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
+                else:
+                    # 创建新用户(原有逻辑)
+                    new_user_id = str(uuid.uuid4())
+                    if not username or username.strip() == "":
+                        username = await self._generate_temp_username(session)
+                    
+                    user = User(
+                        user_id=new_user_id,
+                        username=username,
+                        email=email,
+                        user_type="registered",
+                        is_verified=True,
+                        registered_at=datetime.now(),
+                        current_background_image_id="default",  # 设置默认背景图片
+                    )
+                    session.add(user)
+                    await session.flush()  # 先 flush 确保 user 有 ID,但不提交事务
+                    
+                    final_user_id = new_user_id
+                    
+                    # 初始化背景图片
+                    await self._init_user_background_image(session, new_user_id)
+                    
+                    # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                    await self._init_user_avatar(session, new_user_id, user_type="registered")
+                    
+                    await session.commit()
+                    await session.refresh(user)
             
             # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            final_user_id = final_user_id if final_user_id else user.user_id
             await self.upsert_user_ui_state(
-                user_id=user_id,
+                user_id=final_user_id,
                 current_avatar_id=REGISTERED_AVATAR_ID
             )
             
-            logger.info(f"✅ 邮箱注册成功: user_id={user_id}, email={email}")
+            logger.info(f"✅ 邮箱注册成功: user_id={final_user_id}, email={email}")
             return user.to_dict()
         except Exception as e:
             logger.error(f"❌ 邮箱注册失败: {e}")
@@ -912,6 +1014,37 @@ class UserService:
         except Exception as e:
             logger.error(f"❌ 邮箱登录失败: {e}")
             return None
+    
+    async def check_user_exists_by_qq_openid(self, openid: str, unionid: Optional[str] = None) -> bool:
+        """
+        检查QQ OpenID对应的用户是否存在
+        
+        Args:
+            openid: QQ OpenID
+            unionid: QQ UnionID(可选)
+        
+        Returns:
+            用户是否存在
+        """
+        try:
+            async with db_manager.get_session() as session:
+                # 优先用 openid 查询
+                result = await session.execute(
+                    select(User).where(User.qq_openid == openid)
+                )
+                existing_user = result.scalar_one_or_none()
+                
+                # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
+                if not existing_user and unionid:
+                    union_result = await session.execute(
+                        select(User).where(User.qq_unionid == unionid)
+                    )
+                    existing_user = union_result.scalar_one_or_none()
+                
+                return existing_user is not None
+        except Exception as e:
+            logger.error(f"❌ 检查QQ用户是否存在失败: {e}")
+            return False
 
 
 # 全局用户服务实例

+ 14 - 0
backend/websocket_handler.py

@@ -247,12 +247,26 @@ class WebSocketHandler:
             user_level = level_update.get("user_level")
             if total_duration_ms is not None:
                 await self._maybe_spawn_conversation_stones(total_duration_ms, speech_duration_ms, request_id)
+            # 🔥 即使等级没有变化,也发送 user_level 消息,确保前端能同步 total_duration_ms
+            # 获取当前用户等级(如果更新时等级没变,user_level 可能为 None,需要查询)
             if user_level is not None:
                 await self.send_user_level_update(
                     request_id=request_id,
                     level=user_level,
                     total_duration_ms=total_duration_ms or 0,
                 )
+            elif total_duration_ms is not None:
+                # 等级没变,但总时长更新了,需要查询当前等级并发送消息
+                try:
+                    user_info = await user_service.get_user(self.user_id)
+                    if user_info and user_info.get("user_level") is not None:
+                        await self.send_user_level_update(
+                            request_id=request_id,
+                            level=user_info.get("user_level"),
+                            total_duration_ms=total_duration_ms,
+                        )
+                except Exception as e:
+                    logger.warning(f"[{request_id}] ⚠️ 查询用户等级失败,无法发送 user_level 消息: {e}")
         
         # 检查是否取消
         if request_id in self.cancelled_requests:

+ 6 - 3
frontend/src/components/UserGuard.tsx

@@ -19,7 +19,8 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
 
     if (!hasUserId()) {
       // 当本地没有 user_id 时,仅在需要重定向时才清理并跳转
-      const allowList = ['/welcome', '/naming', '/login'];
+      // 登录流程相关页面允许无 user_id 访问
+      const allowList = ['/welcome', '/naming', '/login', '/login-verification-code', '/login-verification-input'];
       if (!allowList.includes(location.pathname)) {
         // 清理本地路由数据,然后跳转到 /welcome
         clearUserRouteData();
@@ -33,9 +34,11 @@ export const UserGuard = ({ children }: { children: React.ReactNode }) => {
     const userLevel = getUserLevel();
     const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
     const levelWelcomePages = ['/level5-welcome', '/level34-welcome'];
+    // 登录流程相关页面,不应该被重定向回 level5-welcome
+    const loginFlowPages = ['/first-login', '/verification-code', '/verification-input', '/login-verification-code', '/login-verification-input', '/first-login-reward', '/login'];
     
-    if (!isLoggedIn && userLevel >= 5 && !levelWelcomePages.includes(location.pathname)) {
-      // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页,则跳转到 level5-welcome
+    if (!isLoggedIn && userLevel >= 5 && !levelWelcomePages.includes(location.pathname) && !loginFlowPages.includes(location.pathname)) {
+      // 如果用户等级 >= 5 且未登录,且当前不在等级欢迎页或登录流程页,则跳转到 level5-welcome
       navigate('/level5-welcome', { replace: true });
     }
   }, [location.pathname, navigate]);

+ 9 - 31
frontend/src/hooks/useEnergyStones.ts

@@ -6,7 +6,6 @@ const DEFAULT_STONE_SIZES: Record<'mission' | 'conversation', number> = {
   mission: 86,
   conversation: 68,
 };
-const FADE_DURATION_MS = 1500;
 
 interface StonePosition {
   xRatio: number;
@@ -122,7 +121,7 @@ export const useEnergyStones = (
   const [stones, setStones] = useState<EnergyStoneView[]>([]);
   const [containerSize, setContainerSize] = useState(FALLBACK_SIZE);
   const stonesRef = useRef<EnergyStoneView[]>([]);
-  const timersRef = useRef<Map<string, { fade?: number; remove?: number }>>(new Map());
+  const timersRef = useRef<Map<string, { remove?: number }>>(new Map());
 
   useEffect(() => {
     stonesRef.current = stones;
@@ -163,10 +162,7 @@ export const useEnergyStones = (
 
   useEffect(() => {
     return () => {
-      timersRef.current.forEach(({ fade, remove }) => {
-        if (fade) {
-          clearTimeout(fade);
-        }
+      timersRef.current.forEach(({ remove }) => {
         if (remove) {
           clearTimeout(remove);
         }
@@ -177,9 +173,6 @@ export const useEnergyStones = (
 
   const clearTimers = useCallback((stoneId: string) => {
     const timers = timersRef.current.get(stoneId);
-    if (timers?.fade) {
-      clearTimeout(timers.fade);
-    }
     if (timers?.remove) {
       clearTimeout(timers.remove);
     }
@@ -203,15 +196,7 @@ export const useEnergyStones = (
 
   const scheduleLifecycle = useCallback(
     (stone: EnergyStoneView) => {
-      const fadeDelay = Math.max(0, stone.ttlMs - FADE_DURATION_MS);
-      const fadeTimer = window.setTimeout(() => {
-        setStones((prev) =>
-          prev.map((item) =>
-            item.stoneId === stone.stoneId ? { ...item, fading: true } : item
-          )
-        );
-      }, fadeDelay);
-
+      // 超时后直接移除能量石,不使用动画
       const removeTimer = window.setTimeout(() => {
         setStones((prev) => prev.filter((item) => item.stoneId !== stone.stoneId));
         timersRef.current.delete(stone.stoneId);
@@ -220,7 +205,7 @@ export const useEnergyStones = (
         }
       }, stone.ttlMs);
 
-      timersRef.current.set(stone.stoneId, { fade: fadeTimer, remove: removeTimer });
+      timersRef.current.set(stone.stoneId, { remove: removeTimer });
     },
     [eventCallback, toEventPayload]
   );
@@ -278,18 +263,11 @@ export const useEnergyStones = (
       }
 
       clearTimers(stoneId);
-      setStones((prev) =>
-        prev.map((item) =>
-          item.stoneId === stoneId ? { ...item, fading: true } : item
-        )
-      );
-
-      window.setTimeout(() => {
-        setStones((prev) => prev.filter((item) => item.stoneId !== stoneId));
-        if (eventCallback) {
-          eventCallback(stoneId, 'click', toEventPayload(stone));
-        }
-      }, FADE_DURATION_MS);
+      // 直接移除能量石,不设置 fading 状态,不使用动画
+      setStones((prev) => prev.filter((item) => item.stoneId !== stoneId));
+      if (eventCallback) {
+        eventCallback(stoneId, 'click', toEventPayload(stone));
+      }
     },
     [clearTimers, eventCallback, toEventPayload]
   );

+ 43 - 5
frontend/src/main.tsx

@@ -5,20 +5,58 @@ import { logBrowserInfo } from '@/utils/browserDetect';
 import './index.css';
 
 // 全局视口高度修复:为所有使用 px2vh / vh 的地方提供统一、准确的高度基准
+// 修复键盘弹出导致界面缩小的问题:固定初始视口高度,键盘弹出时不更新
 const installViewportHeightFix = () => {
   if (typeof window === 'undefined') return;
 
-  const setViewportHeight = () => {
-    const height = window.visualViewport?.height ?? window.innerHeight;
+  // 记录初始视口高度(键盘弹出前的高度)
+  let initialHeight = window.innerHeight;
+  let isInitialized = false;
+
+  const setViewportHeight = (forceUpdate = false) => {
+    // 首次加载时,记录初始高度
+    if (!isInitialized) {
+      initialHeight = window.innerHeight;
+      isInitialized = true;
+    }
+
+    // 如果强制更新(如横竖屏切换),使用当前高度
+    // 否则使用初始高度,避免键盘弹出时界面缩小
+    const height = forceUpdate 
+      ? (window.visualViewport?.height ?? window.innerHeight)
+      : initialHeight;
+    
     const vh = height * 0.01;
     // 全局 1vh 的真实像素值
     document.documentElement.style.setProperty('--vh', `${vh}px`);
   };
 
+  // 首次设置
   setViewportHeight();
-  window.addEventListener('resize', setViewportHeight);
-  window.addEventListener('orientationchange', setViewportHeight);
-  window.visualViewport?.addEventListener('resize', setViewportHeight);
+
+  // 监听横竖屏切换(真正的视口变化,需要更新)
+  window.addEventListener('orientationchange', () => {
+    // 延迟执行,等待实际尺寸变化
+    setTimeout(() => {
+      initialHeight = window.innerHeight; // 更新初始高度
+      setViewportHeight(true);
+    }, 100);
+  });
+
+  // 监听窗口大小变化(仅处理真正的视口变化,如浏览器窗口调整)
+  // 使用防抖避免频繁更新
+  let resizeTimer: NodeJS.Timeout | null = null;
+  window.addEventListener('resize', () => {
+    if (resizeTimer) clearTimeout(resizeTimer);
+    resizeTimer = setTimeout(() => {
+      // 判断是否为真正的视口变化(高度变化超过 200px 认为是横竖屏切换)
+      const currentHeight = window.innerHeight;
+      if (Math.abs(currentHeight - initialHeight) > 200) {
+        initialHeight = currentHeight;
+        setViewportHeight(true);
+      }
+    }, 150);
+  });
 };
 
 installViewportHeightFix();

+ 48 - 27
frontend/src/pages/ChatBotPage.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import { v4 as uuidv4 } from 'uuid';
 import { useAppStore } from '@/store';
@@ -21,6 +21,7 @@ import {
   getCurrentBackgroundImageId,
   getCurrentBackgroundImageOpacity,
   syncBackgroundImageToStorage,
+  setUserName,
 } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
@@ -94,6 +95,7 @@ const getEmotionCategory = (emotion: string): 'positive' | 'negative' | 'neutral
 
 export const ChatBotPage = () => {
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const {
     currentState,
     currentRequestId,
@@ -132,8 +134,8 @@ export const ChatBotPage = () => {
     const url = getCurrentBackgroundImageUrl();
     return url || null;
   });
-  // 背景图片 ID
-  const [backgroundImageId, setBackgroundImageId] = useState<string | null>(() => {
+  // 背景图片 ID(只保留 setter,因为变量本身未使用)
+  const [, setBackgroundImageId] = useState<string | null>(() => {
     return getCurrentBackgroundImageId();
   });
   // 初始化时检查 localStorage,确保第一次打开时立即显示提示
@@ -216,7 +218,7 @@ export const ChatBotPage = () => {
   const [displayProgress, setDisplayProgress] = useState(0);
   const animationFrameRef = useRef<number>();
   const totalProgressRef = useRef(0);
-  const shouldSkipProgressAnimationRef = useRef(true);
+  const shouldSkipProgressAnimationRef = useRef(false);
 
   const recordingTimerRef = useRef<number | null>(null);
   const recordingDeadlineRef = useRef<number>(0);
@@ -781,29 +783,7 @@ export const ChatBotPage = () => {
     }
   }, []);
 
-  // 监听能量石的 fading 状态,当开始消失动画时,计算当前位置并设置为固定定位
-  useEffect(() => {
-    energyStones.forEach((stone) => {
-      if (stone.fading) {
-        // 使用 requestAnimationFrame 确保 DOM 更新后再设置位置
-        requestAnimationFrame(() => {
-          const element = document.querySelector(`[data-stone-id="${stone.stoneId}"]`) as HTMLElement;
-          if (element) {
-            const rect = element.getBoundingClientRect();
-            // 计算能量石中心的屏幕位置
-            const centerX = rect.left + rect.width / 2;
-            const centerY = rect.top + rect.height / 2;
-            // 设置为固定定位,并设置起始位置
-            element.style.position = 'fixed';
-            element.style.left = `${centerX}px`;
-            element.style.top = `${centerY}px`;
-            element.style.transform = 'translate(-50%, -50%) scale(1)';
-            element.style.opacity = '1';
-          }
-        });
-      }
-    });
-  }, [energyStones]);
+  // 能量石点击后直接消失,不再需要监听 fading 状态
 
   // 监听 localStorage 中背景图片的变化
   useEffect(() => {
@@ -837,6 +817,22 @@ export const ChatBotPage = () => {
     };
   }, [syncBackgroundImageState]);
 
+  // 处理OAuth登录回调(QQ登录成功后直接跳转到chatbot)
+  useEffect(() => {
+    const oauth = searchParams.get('oauth');
+    const userId = searchParams.get('user_id');
+    const usernameParam = searchParams.get('username');
+    
+    if (oauth === 'qq' && userId && usernameParam) {
+      // QQ登录成功,保存用户信息
+      setStorage(STORAGE_KEYS.USER_ID, userId);
+      setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
+      antMessage.success('QQ登录成功');
+      // 清除URL参数
+      navigate('/chatbot', { replace: true });
+    }
+  }, [searchParams, navigate]);
+
   // 初始化
   useEffect(() => {
     // 记录用户当前所在页面
@@ -966,6 +962,15 @@ export const ChatBotPage = () => {
           try {
             const userInfo = await getUserInfo(user_id);
             if (userInfo.success && userInfo.data) {
+              // 同步用户名到 localStorage
+              if (userInfo.data.username) {
+                setUserName(userInfo.data.username);
+              }
+              // 同步机器人名字到 localStorage
+              if (userInfo.data.bot_name) {
+                setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
+              }
+              // 同步用户等级
               const totalDurationMs = userInfo.data.total_speech_duration_ms ?? 0;
               if (typeof userInfo.data.user_level === 'number') {
                 syncUserLevel(userInfo.data.user_level);
@@ -1160,6 +1165,22 @@ export const ChatBotPage = () => {
         if (typeof message.level === 'number') {
           syncUserLevel(message.level);
         }
+        // 🔥 使用后端返回的 totalDurationMs 同步进度条,确保与数据库一致
+        if (typeof message.totalDurationMs === 'number' && message.totalDurationMs > 0) {
+          const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
+          // 更新本地累加器,确保与数据库一致
+          totalSpeechDurationRef.current = Math.max(
+            message.totalDurationMs,
+            avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
+          );
+          // 根据总时长计算进度条位置
+          const totalProgressPercent = (message.totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
+          const loopCount = Math.floor(totalProgressPercent / 100);
+          const progress = totalProgressPercent % 100;
+          // 跳过动画,直接同步到后端返回的值
+          shouldSkipProgressAnimationRef.current = true;
+          setProgress(progress, loopCount);
+        }
         break;
 
       case 'emotion':

+ 3 - 3
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -10,9 +10,9 @@ const shareProgressBar = getOssUrl('share-progress-bar.png');
 const avatarAnimaZone = getOssUrl('avatar-anima-zone-copy.png');
 const shareBtn = getOssUrl('share-btn.png');
 const closeBtn = getOssUrl('close-btn.png');
-const wechatIcon = getOssUrl('wechat.png');
-const qqIcon = getOssUrl('QQ.png');
-const saveIcon = getOssUrl('save.png');
+// const wechatIcon = getOssUrl('wechat.png'); // 已注释,未使用
+// const qqIcon = getOssUrl('QQ.png'); // 已注释,未使用
+// const saveIcon = getOssUrl('save.png'); // 已注释,未使用
 const saveBtnImage = getOssUrl('Save-btn.png');
 import { shareImages as fallbackShareImages } from '@/data/shareImages';
 import { getShareImages, type ShareImageItem } from '@/utils/api';

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

@@ -9,7 +9,6 @@ import {
   getUserId,
   getVisitedRoutes,
   setUserId,
-  setUserName,
   setUserLevel,
 } from '../utils/storage';
 import { getUserInfo, updateUsername, updateUserUIState, createUser } from '../utils/api';

+ 4 - 4
frontend/src/pages/Level34WelcomePage.tsx

@@ -111,7 +111,7 @@ export const Level34WelcomePage = () => {
           animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
         }}
       />
-      {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示,5.4秒后消失 */}
+      {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示 */}
       <div
         style={{
           position: 'absolute',
@@ -123,12 +123,12 @@ export const Level34WelcomePage = () => {
           fontWeight: 'bold',
           whiteSpace: 'nowrap',
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+          animation: 'textSlideUpAndShake 1s ease-out 2s forwards',
         }}
       >
         Congratulation!
       </div>
-      {/* 文字 - 恭喜你成功升到5级!... - Y: 615, 左右居中, 16px白色,动画结束后显示,5.4秒后消失 */}
+      {/* 文字 - 恭喜你成功升到5级!... - Y: 615, 左右居中, 16px白色,动画结束后显示 */}
       <div
         style={{
           position: 'absolute',
@@ -140,7 +140,7 @@ export const Level34WelcomePage = () => {
           textAlign: 'center',
           width: px2vmin(270),
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+          animation: 'textSlideUpAndShake 1s ease-out 2s forwards',
         }}
       >
         恭喜你成功升到{userLevel}级!聊了这么长时间,不如登陆一下让我们保持联系吧。登陆后有惊喜礼物等着你哦

+ 13 - 4
frontend/src/pages/Level5WelcomePage.tsx

@@ -57,6 +57,7 @@ export const Level5WelcomePage = () => {
           transform: 'translateX(-50%)',
           opacity: 0,
           animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
+          animationIterationCount: 1,
         }}
       />
       {/* GIF动画图片 - Y: 192, 左右居中, width: 343, height: 280,动画结束后显示 */}
@@ -72,9 +73,10 @@ export const Level5WelcomePage = () => {
           transform: 'translateX(-50%)',
           opacity: 0,
           animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
+          animationIterationCount: 1,
         }}
       />
-      {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示,5.4秒后消失 */}
+      {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示 */}
       <div
         style={{
           position: 'absolute',
@@ -86,12 +88,13 @@ export const Level5WelcomePage = () => {
           fontWeight: 'bold',
           whiteSpace: 'nowrap',
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+          animation: 'textSlideUpAndShake 1s ease-out 2s forwards',
+          animationIterationCount: 1,
         }}
       >
         Congratulation!
       </div>
-      {/* 文字 - 恭喜你成功升到5级!... - Y: 615, 左右居中, 16px白色,动画结束后显示,5.4秒后消失 */}
+      {/* 文字 - 恭喜你成功升到5级!... - Y: 615, 左右居中, 16px白色,动画结束后显示 */}
       <div
         style={{
           position: 'absolute',
@@ -103,7 +106,8 @@ export const Level5WelcomePage = () => {
           textAlign: 'center',
           width: px2vmin(270),
           opacity: 0,
-          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+          animation: 'textSlideUpAndShake 1s ease-out 2s forwards',
+          animationIterationCount: 1,
         }}
       >
         恭喜你成功升到5级!聊了这么长时间,不如登陆一下让我们保持联系吧。登陆后有惊喜礼物等着你哦
@@ -125,6 +129,7 @@ export const Level5WelcomePage = () => {
           zIndex: 10,
           cursor: 'pointer',
           animation: 'textSlideUpAndShake 1s ease-out 2s forwards',
+          animationIterationCount: 1,
         }}
         aria-label="Register for a gift"
       >
@@ -151,6 +156,7 @@ export const Level5WelcomePage = () => {
           background: 'rgb(56, 54, 67)',
           transform: 'translate(-50%, -50%)',
           animation: 'circleExpand 1.5s ease-out forwards',
+          animationIterationCount: 1,
         }}
       />
       {/* 第二个圆 - 圆心(-26.5, 26.5),半径332.5,从圆心扩展 */}
@@ -165,6 +171,7 @@ export const Level5WelcomePage = () => {
           background: 'rgb(56, 54, 67)',
           transform: 'translate(-50%, -50%)',
           animation: 'circleExpand2 1.5s ease-out forwards',
+          animationIterationCount: 1,
         }}
       />
       {/* 第三个圆 - 圆心(123, 572),半径340,从圆心扩展 */}
@@ -179,6 +186,7 @@ export const Level5WelcomePage = () => {
           background: 'rgb(56, 54, 67)',
           transform: 'translate(-50%, -50%)',
           animation: 'circleExpand3 1.5s ease-out forwards',
+          animationIterationCount: 1,
         }}
       />
       {/* 第四个圆 - 圆心(398.5, 829.5),半径258.5,从圆心扩展 */}
@@ -193,6 +201,7 @@ export const Level5WelcomePage = () => {
           background: 'rgb(56, 54, 67)',
           transform: 'translate(-50%, -50%)',
           animation: 'circleExpand4 1.5s ease-out forwards',
+          animationIterationCount: 1,
         }}
       />
       <style>{`

+ 73 - 23
frontend/src/pages/LoginPage.tsx

@@ -1,7 +1,8 @@
 import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
+import { message } from 'antd';
 import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState } from '@/utils/api';
+import { updateUserUIState, loginWithPhone, loginWithEmail, getQQAuthUrl } from '@/utils/api';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -34,6 +35,7 @@ export const LoginPage = () => {
   const [selectedLoginType, setSelectedLoginType] = useState<'phone' | 'qq' | 'email'>(
     state?.loginType || 'phone'
   );
+  const [isChecking, setIsChecking] = useState(false);
   const areaCodeRef = useRef<HTMLInputElement>(null);
   const phoneRef = useRef<HTMLInputElement>(null);
   const emailRef = useRef<HTMLInputElement>(null);
@@ -58,6 +60,74 @@ export const LoginPage = () => {
     navigate('/welcome');
   };
 
+  // 处理 continue 按钮点击
+  const handleContinue = async () => {
+    // 手机号登录
+    if (selectedLoginType === 'phone' && phoneNumber) {
+      if (isChecking) return;
+      setIsChecking(true);
+      try {
+        // 检查用户是否存在
+        await loginWithPhone(phoneNumber);
+        // 用户存在,跳转到验证码页面
+        navigate('/login-verification-code', {
+          state: {
+            loginType: 'phone',
+            areaCode,
+            phoneNumber,
+            email, // 保留邮箱数据,以便返回时恢复
+          },
+        });
+      } catch (error) {
+        // 用户不存在,提示并返回 welcome 页面
+        const errorMessage = error instanceof Error ? error.message : '登录失败';
+        if (errorMessage.includes('404') || errorMessage.includes('用户不存在')) {
+          message.error('用户未注册,请先注册');
+          navigate('/welcome');
+        } else {
+          message.error(errorMessage);
+        }
+      } finally {
+        setIsChecking(false);
+      }
+    }
+    // 邮箱登录
+    else if (selectedLoginType === 'email' && email) {
+      if (isChecking) return;
+      setIsChecking(true);
+      try {
+        // 检查用户是否存在
+        await loginWithEmail(email);
+        // 用户存在,跳转到验证码页面
+        navigate('/login-verification-code', {
+          state: {
+            loginType: 'email',
+            areaCode, // 保留区号数据,以便返回时恢复
+            phoneNumber, // 保留手机号数据,以便返回时恢复
+            email,
+          },
+        });
+      } catch (error) {
+        // 用户不存在,提示并返回 welcome 页面
+        const errorMessage = error instanceof Error ? error.message : '登录失败';
+        if (errorMessage.includes('404') || errorMessage.includes('用户不存在')) {
+          message.error('用户未注册,请先注册');
+          navigate('/welcome');
+        } else {
+          message.error(errorMessage);
+        }
+      } finally {
+        setIsChecking(false);
+      }
+    }
+    // QQ登录
+    else if (selectedLoginType === 'qq') {
+      // 跳转到QQ授权页面
+      const qqAuthUrl = getQQAuthUrl();
+      window.location.href = qqAuthUrl;
+    }
+  };
+
   return (
     <div>
       {/* 顶部返回按钮 */}
@@ -354,28 +424,8 @@ export const LoginPage = () => {
       {/* Continue 按钮 */}
       <button
         type="button"
-        onClick={() => {
-          // 手机号码或邮箱登录时,跳转到登录验证码页面
-          if (selectedLoginType === 'phone' && phoneNumber) {
-            navigate('/login-verification-code', {
-              state: {
-                loginType: 'phone',
-                areaCode,
-                phoneNumber,
-                email, // 保留邮箱数据,以便返回时恢复
-              },
-            });
-          } else if (selectedLoginType === 'email' && email) {
-            navigate('/login-verification-code', {
-              state: {
-                loginType: 'email',
-                areaCode, // 保留区号数据,以便返回时恢复
-                phoneNumber, // 保留手机号数据,以便返回时恢复
-                email,
-              },
-            });
-          }
-        }}
+        onClick={handleContinue}
+        disabled={isChecking}
         style={{
           position: 'absolute',
           left: '50%',

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

@@ -6,7 +6,7 @@ import { updateUserUIState, sendCode } from '@/utils/api';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
-const giftProgressBar = getOssUrl('gift-progress-bar.png');
+// const giftProgressBar = getOssUrl('gift-progress-bar.png'); // 已注释,未使用
 const sendCodeNo = getOssUrl('send-code-no.png');
 const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
@@ -83,6 +83,7 @@ export const LoginVerificationCodePage = () => {
       
       // 发送验证码
       const response = await sendCode({
+        area_code: selectedLoginType === 'phone' ? areaCode : undefined,
         phone: selectedLoginType === 'phone' ? phoneNumber : undefined,
         email: selectedLoginType === 'email' ? email : undefined,
         send_type: sendType,

+ 93 - 12
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -1,8 +1,8 @@
 import { useEffect, useState, useRef, useCallback } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
 import { message } from 'antd';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState, verifyCode, loginWithPhone, loginWithEmail } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } from '@/utils/storage';
+import { updateUserUIState, verifyCode, loginWithPhone, loginWithEmail, sendCode, getUserInfo } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -149,9 +149,36 @@ export const LoginVerificationInputPage = () => {
       }
 
       if (response.success && response.data) {
-        // 保存用户信息
-        setStorage(STORAGE_KEYS.USER_ID, response.data.user_id);
-        setStorage(STORAGE_KEYS.USER_NAME, response.data.username);
+        // 保存基本用户信息
+        const userId = response.data.user_id;
+        setStorage(STORAGE_KEYS.USER_ID, userId);
+        if (response.data.username) {
+          setStorage(STORAGE_KEYS.USER_NAME, response.data.username);
+        }
+        
+        // 获取完整用户信息并同步到 localStorage
+        try {
+          const userInfo = await getUserInfo(userId);
+          if (userInfo.success && userInfo.data) {
+            // 同步用户名
+            if (userInfo.data.username) {
+              setUserName(userInfo.data.username);
+            }
+            // 同步机器人名字
+            if (userInfo.data.bot_name) {
+              setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
+            }
+            // 同步用户等级
+            if (typeof userInfo.data.user_level === 'number') {
+              setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfo.data.user_level));
+            }
+            // 背景图片会在 getUserInfo 中自动同步
+          }
+        } catch (error) {
+          // 获取完整用户信息失败不影响登录流程,静默处理
+          console.warn('获取完整用户信息失败:', error);
+        }
+        
         message.success('登录成功');
         
         // 跳转到聊天界面
@@ -220,13 +247,48 @@ export const LoginVerificationInputPage = () => {
   }, [isCodeComplete, codes.join(''), state?.phoneNumber, state?.email, state?.loginType, isVerifying, isCodeValid, isLoggingIn, handleLogin]);
 
   // 处理发送验证码按钮点击
-  const handleSendCode = () => {
-    if (countdown === 0) {
-      // 重新开始倒计时
-      setCountdown(60);
-      // TODO: 这里可以添加发送验证码的API调用
-      // 返回登录验证码页面重新发送
-      handleBack();
+  const handleSendCode = async () => {
+    if (countdown > 0) {
+      return;
+    }
+
+    // 验证输入
+    if (state?.loginType === 'phone' && !state?.phoneNumber) {
+      message.error('手机号码不能为空');
+      return;
+    }
+    if (state?.loginType === 'email' && !state?.email) {
+      message.error('邮箱地址不能为空');
+      return;
+    }
+
+    try {
+      // 确定发送类型
+      const sendType = state?.loginType === 'email' ? 'email' : 'domestic_sms';
+      
+      // 发送验证码
+      const response = await sendCode({
+        area_code: state?.loginType === 'phone' ? state?.areaCode : undefined,
+        phone: state?.loginType === 'phone' ? state?.phoneNumber : undefined,
+        email: state?.loginType === 'email' ? state?.email : undefined,
+        send_type: sendType,
+      });
+
+      if (response.success) {
+        message.success('验证码已发送');
+        // 重新开始倒计时
+        setCountdown(60);
+        // 清空验证码输入框
+        setCodes(['', '', '', '']);
+        codeRefs[0].current?.focus();
+        setFocusedIndex(0);
+        setIsCodeValid(false);
+      } else {
+        message.error('发送验证码失败,请重试');
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '发送验证码失败';
+      message.error(errorMessage);
     }
   };
 
@@ -273,6 +335,25 @@ export const LoginVerificationInputPage = () => {
           transform: 'translateX(-50%)',
         }}
       />
+      {/* 验证码发送提示文字 */}
+      <div
+        className="absolute"
+        style={{
+          top: px2vmin(304),
+          left: '50%',
+          transform: 'translateX(-50%)',
+          width: px2vmin(312),
+          height: px2vmin(16),
+          fontSize: px2vmin(12),
+          color: '#FFFFFF',
+          textAlign: 'left',
+          lineHeight: px2vmin(16),
+        }}
+      >
+        {state?.loginType === 'email'
+          ? `验证码已通过邮箱发送至 ${state?.email || ''}`
+          : `验证码已通过短信发送至 ${state?.phoneNumber || ''}`}
+      </div>
       {/* 验证码输入框 */}
       <div
         className="absolute"

+ 9 - 3
frontend/src/pages/RegisterPage.tsx

@@ -16,7 +16,7 @@ import {
   getWechatAuthUrl,
   getQQAuthUrl
 } from '@/utils/api';
-import { setStorage, STORAGE_KEYS } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, getUserId } from '@/utils/storage';
 import { browser } from '@/utils/browserDetect';
 import { px2vw } from '@/utils/viewport';
 import './RegisterPage.css';
@@ -88,11 +88,14 @@ export const RegisterPage = () => {
 
     setLoading(true);
     try {
+      // 获取当前用户的 user_id(如果存在)
+      const currentUserId = getUserId();
+      
       let response;
       if (isLogin) {
         response = await loginWithPhone(phone);
       } else {
-        response = await registerWithPhone(phone, username);
+        response = await registerWithPhone(phone, username, currentUserId);
       }
 
       if (response.success && response.data) {
@@ -126,11 +129,14 @@ export const RegisterPage = () => {
 
     setLoading(true);
     try {
+      // 获取当前用户的 user_id(如果存在)
+      const currentUserId = getUserId();
+      
       let response;
       if (isLogin) {
         response = await loginWithEmail(email);
       } else {
-        response = await registerWithEmail(email, username);
+        response = await registerWithEmail(email, username, currentUserId);
       }
 
       if (response.success && response.data) {

+ 43 - 17
frontend/src/pages/VerificationCodePage.tsx

@@ -1,13 +1,14 @@
 import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
+import { message } from 'antd';
 import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState } from '@/utils/api';
+import { updateUserUIState, sendCode } from '@/utils/api';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
 const giftProgressBar = getOssUrl('gift-progress-bar.png');
 const sendCodeNo = getOssUrl('send-code-no.png');
-const sendCode = getOssUrl('send-code.png');
+const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
 
 interface LocationState {
@@ -64,21 +65,46 @@ export const VerificationCodePage = () => {
   };
 
   // 处理发送验证码按钮点击
-  const handleSendCode = () => {
-    // 检查是否有有效的手机号或邮箱
-    const isValid = 
-      (selectedLoginType === 'phone' && phoneNumber) ||
-      (selectedLoginType === 'email' && email);
-    
-    if (isValid) {
-      navigate('/verification-input', {
-        state: {
-          loginType: selectedLoginType,
-          areaCode,
-          phoneNumber,
-          email,
-        },
+  const handleSendCode = async () => {
+    // 验证输入
+    if (selectedLoginType === 'phone' && !phoneNumber) {
+      message.error('请输入手机号码');
+      return;
+    }
+    if (selectedLoginType === 'email' && !email) {
+      message.error('请输入邮箱地址');
+      return;
+    }
+
+    try {
+      // 确定发送类型
+      const sendType = selectedLoginType === 'email' ? 'email' : 'domestic_sms';
+      
+      // 发送验证码
+      const response = await sendCode({
+        area_code: selectedLoginType === 'phone' ? areaCode : undefined,
+        phone: selectedLoginType === 'phone' ? phoneNumber : undefined,
+        email: selectedLoginType === 'email' ? email : undefined,
+        send_type: sendType,
       });
+
+      if (response.success) {
+        message.success('验证码已发送');
+        // 跳转到验证码输入页面
+        navigate('/verification-input', {
+          state: {
+            loginType: selectedLoginType,
+            areaCode,
+            phoneNumber,
+            email,
+          },
+        });
+      } else {
+        message.error('发送验证码失败,请重试');
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '发送验证码失败';
+      message.error(errorMessage);
     }
   };
 
@@ -376,7 +402,7 @@ export const VerificationCodePage = () => {
           src={
             (selectedLoginType === 'phone' && phoneNumber) ||
             (selectedLoginType === 'email' && email)
-              ? sendCode
+              ? sendCodeImage
               : sendCodeNo
           }
           alt="send code"

+ 111 - 8
frontend/src/pages/VerificationInputPage.tsx

@@ -1,7 +1,8 @@
 import { useEffect, useState, useRef } from 'react';
 import { useLocation, useNavigate } from 'react-router-dom';
+import { message } from 'antd';
 import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState, verifyCode } from '@/utils/api';
+import { updateUserUIState, verifyCode, sendCode, registerWithPhone, registerWithEmail } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -148,6 +149,7 @@ export const VerificationInputPage = () => {
             setCodes(['', '', '', '']);
             codeRefs[0].current?.focus();
             setFocusedIndex(0);
+            message.error('验证码错误,请重新输入');
           }
         } catch (error) {
           console.error('验证验证码失败:', error);
@@ -156,6 +158,7 @@ export const VerificationInputPage = () => {
           setCodes(['', '', '', '']);
           codeRefs[0].current?.focus();
           setFocusedIndex(0);
+          message.error('验证码验证失败,请重试');
         } finally {
           setIsVerifying(false);
         }
@@ -171,17 +174,98 @@ export const VerificationInputPage = () => {
   }, [isCodeComplete, codes.join(''), state?.phoneNumber, state?.email, state?.loginType, isVerifying, isCodeValid]);
 
   // 处理发送验证码按钮点击
-  const handleSendCode = () => {
-    if (countdown === 0) {
-      // 重新开始倒计时
-      setCountdown(60);
-      // TODO: 这里可以添加发送验证码的API调用
+  const handleSendCode = async () => {
+    if (countdown > 0) {
+      return;
+    }
+
+    // 验证输入
+    if (state?.loginType === 'phone' && !state?.phoneNumber) {
+      message.error('手机号码不能为空');
+      return;
+    }
+    if (state?.loginType === 'email' && !state?.email) {
+      message.error('邮箱地址不能为空');
+      return;
+    }
+
+    try {
+      // 确定发送类型
+      const sendType = state?.loginType === 'email' ? 'email' : 'domestic_sms';
+      
+      // 发送验证码
+      const response = await sendCode({
+        area_code: state?.loginType === 'phone' ? state?.areaCode : undefined,
+        phone: state?.loginType === 'phone' ? state?.phoneNumber : undefined,
+        email: state?.loginType === 'email' ? state?.email : undefined,
+        send_type: sendType,
+      });
+
+      if (response.success) {
+        message.success('验证码已发送');
+        // 重新开始倒计时
+        setCountdown(60);
+        // 清空验证码输入框
+        setCodes(['', '', '', '']);
+        codeRefs[0].current?.focus();
+        setFocusedIndex(0);
+      } else {
+        message.error('发送验证码失败,请重试');
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '发送验证码失败';
+      message.error(errorMessage);
     }
   };
 
   // 处理注册礼品按钮点击
-  const handleRegisterForGift = () => {
-    navigate('/first-login-reward');
+  const handleRegisterForGift = async () => {
+    // 验证输入
+    if (state?.loginType === 'phone' && !state?.phoneNumber) {
+      message.error('手机号码不能为空');
+      return;
+    }
+    if (state?.loginType === 'email' && !state?.email) {
+      message.error('邮箱地址不能为空');
+      return;
+    }
+
+    try {
+      // 获取当前用户的 user_id(如果存在)
+      const currentUserId = getUserId();
+      
+      let response;
+      
+      // 根据登录类型调用对应的注册接口,传递现有的 user_id
+      if (state?.loginType === 'email' && state?.email) {
+        response = await registerWithEmail(state.email, '', currentUserId);
+      } else if (state?.phoneNumber) {
+        response = await registerWithPhone(state.phoneNumber, '', currentUserId);
+      } else {
+        message.error('注册信息不完整');
+        return;
+      }
+
+      if (response.success && response.data) {
+        // 保存用户信息到 localStorage(如果 user_id 发生变化则更新)
+        if (response.data.user_id) {
+          setStorage(STORAGE_KEYS.USER_ID, response.data.user_id);
+        }
+        if (response.data.username) {
+          setStorage(STORAGE_KEYS.USER_NAME, response.data.username);
+        }
+        
+        message.success('注册成功!已获得帽子奖励');
+        
+        // 跳转到奖励页面
+        navigate('/first-login-reward');
+      } else {
+        message.error('注册失败,请重试');
+      }
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : '注册失败';
+      message.error(errorMessage);
+    }
   };
 
   return (
@@ -239,6 +323,25 @@ export const VerificationInputPage = () => {
           transform: 'translateX(-50%)',
         }}
       />
+      {/* 验证码发送提示文字 */}
+      <div
+        className="absolute"
+        style={{
+          top: px2vmin(304),
+          left: '50%',
+          transform: 'translateX(-50%)',
+          width: px2vmin(312),
+          height: px2vmin(16),
+          fontSize: px2vmin(12),
+          color: '#FFFFFF',
+          textAlign: 'left',
+          lineHeight: px2vmin(16),
+        }}
+      >
+        {state?.loginType === 'email'
+          ? `验证码已通过邮箱发送至 ${state?.email || ''}`
+          : `验证码已通过短信发送至 ${state?.phoneNumber || ''}`}
+      </div>
       {/* 验证码输入框 */}
       <div
         className="absolute"

+ 16 - 3
frontend/src/pages/WelcomePage.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
 import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage } from '@/utils/storage';
@@ -17,6 +17,7 @@ type WelcomeViewState = 'welcome' | 'loading' | 'playing';
 
 export const WelcomePage = () => {
   const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
   const videoRef = useRef<HTMLVideoElement>(null);
   const playerRef = useRef<VideoJsPlayer | null>(null);
   const [viewState, setViewState] = useState<WelcomeViewState>('welcome');
@@ -29,8 +30,20 @@ export const WelcomePage = () => {
   const hasNavigatedRef = useRef(false); // 是否已经跳转到 /naming,防止重复执行
   const checkIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); // 检查定时器
   const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 兜底定时器
-  const [showSkipButton, setShowSkipButton] = useState(false); // 是否展示“跳过动画”按钮(只供用户手动跳转)
-  const hasShownTimeoutTipRef = useRef(false); // 是否已经提示过“响应超时”
+  const [showSkipButton, setShowSkipButton] = useState(false); // 是否展示"跳过动画"按钮(只供用户手动跳转)
+  const hasShownTimeoutTipRef = useRef(false); // 是否已经提示过"响应超时"
+
+  // 处理错误参数(如QQ登录时用户未注册)
+  useEffect(() => {
+    const error = searchParams.get('error');
+    const oauth = searchParams.get('oauth');
+    
+    if (error === 'user_not_registered' && oauth === 'qq') {
+      antMessage.error('用户未注册,请先注册');
+      // 清除URL参数
+      navigate('/welcome', { replace: true });
+    }
+  }, [searchParams, navigate]);
 
   useEffect(() => {
     console.log('[WelcomePage] init effect start');

+ 13 - 4
frontend/src/utils/api.ts

@@ -290,14 +290,18 @@ export interface RegisterPhoneResponse {
   };
 }
 
-export const registerWithPhone = async (phone: string, username: string = ''): Promise<RegisterPhoneResponse> => {
+export const registerWithPhone = async (phone: string, username: string = '', user_id?: string | null): Promise<RegisterPhoneResponse> => {
   const apiBaseUrl = getApiBaseUrl();
+  const body: { phone: string; username: string; user_id?: string } = { phone, username };
+  if (user_id) {
+    body.user_id = user_id;
+  }
   const response = await fetch(`${apiBaseUrl}/api/auth/register/phone`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
-    body: JSON.stringify({ phone, username }),
+    body: JSON.stringify(body),
   });
 
   if (!response.ok) {
@@ -321,14 +325,18 @@ export interface RegisterEmailResponse {
   };
 }
 
-export const registerWithEmail = async (email: string, username: string = ''): Promise<RegisterEmailResponse> => {
+export const registerWithEmail = async (email: string, username: string = '', user_id?: string | null): Promise<RegisterEmailResponse> => {
   const apiBaseUrl = getApiBaseUrl();
+  const body: { email: string; username: string; user_id?: string } = { email, username };
+  if (user_id) {
+    body.user_id = user_id;
+  }
   const response = await fetch(`${apiBaseUrl}/api/auth/register/email`, {
     method: 'POST',
     headers: {
       'Content-Type': 'application/json',
     },
-    body: JSON.stringify({ email, username }),
+    body: JSON.stringify(body),
   });
 
   if (!response.ok) {
@@ -465,6 +473,7 @@ export const loginWithEmail = async (email: string): Promise<LoginEmailResponse>
 
 // 发送验证码
 export interface SendCodeRequest {
+  area_code?: string;  // 手机区号(如 +86)
   phone?: string;
   email?: string;
   send_type: 'domestic_sms' | 'international_sms' | 'email';