zlwu 11 bulan lalu
induk
melakukan
9ef59f10f7

+ 69 - 0
backend/migrate_add_user_level.py

@@ -0,0 +1,69 @@
+"""
+数据库迁移脚本:为 users 表添加 user_level 字段
+运行: python migrate_add_user_level.py
+"""
+import asyncio
+import sys
+import aiomysql
+from config import settings
+
+
+async def migrate_add_user_level():
+    """为 users 表添加 user_level 字段"""
+    try:
+        conn = 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 = 'users'
+                  AND COLUMN_NAME = 'user_level'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            result = await cursor.fetchone()
+            if result and result[0] > 0:
+                conn.close()
+                return True
+
+            await cursor.execute(
+                """
+                ALTER TABLE users
+                ADD COLUMN user_level INT NOT NULL DEFAULT 0 COMMENT '用户等级(0-5)'
+                AFTER total_speech_duration_ms
+                """
+            )
+            await conn.commit()
+
+        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)
+
+    success = asyncio.run(migrate_add_user_level())
+    if not success:
+        sys.exit(1)
+

+ 8 - 0
backend/models.py

@@ -18,6 +18,7 @@ MessageType = Literal[
     "pong",
     "energy_stone_spawn",
     "energy_stone_event",
+    "user_level",
 ]
 
 # 情感类型
@@ -145,3 +146,10 @@ class EnergyStoneEventMessage(BaseMessage):
     clientTs: Optional[int] = None
     position: Optional[Dict] = None
 
+
+class UserLevelMessage(BaseMessage):
+    """用户等级更新消息"""
+    type: Literal["user_level"] = "user_level"
+    level: int
+    totalDurationMs: int
+

+ 2 - 0
backend/models_db.py

@@ -52,6 +52,7 @@ class User(Base):
     total_messages = Column(Integer, default=0, comment="总消息数")
     total_rounds = Column(Integer, default=0, comment="总对话轮次")
     total_speech_duration_ms = Column(BigInteger, default=0, comment="累积说话时长(毫秒)")
+    user_level = Column(Integer, default=0, nullable=False, comment="用户等级(0-5)")
     last_active_at = Column(TIMESTAMP, nullable=True, comment="最后活跃时间")
     
     created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
@@ -75,6 +76,7 @@ class User(Base):
             "total_messages": self.total_messages,
             "total_rounds": self.total_rounds,
             "total_speech_duration_ms": self.total_speech_duration_ms,
+            "user_level": self.user_level,
             "last_active_at": self.last_active_at.isoformat() if self.last_active_at else None,
             "created_at": self.created_at.isoformat() if self.created_at else None,
         }

+ 43 - 8
backend/services/user_service.py

@@ -5,7 +5,7 @@ import uuid
 import random
 import asyncio
 import logging
-from typing import Dict, Optional, Any
+from typing import Dict, Optional, Any, Tuple
 from datetime import datetime
 from sqlalchemy import select, update, func, insert
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,9 +14,27 @@ from models_db import User, UserUIState
 
 logger = logging.getLogger(__name__)
 
+LEVEL_THRESHOLDS_MS: Tuple[Tuple[int, int], ...] = (
+    (5, 240_000),
+    (4, 120_000),
+    (3, 60_000),
+    (2, 30_000),
+    (1, 10_000),
+)
+
 
 class UserService:
     """用户管理服务"""
+
+    @staticmethod
+    def _calculate_user_level(total_ms: int) -> int:
+        """
+        根据累计说话时长计算用户等级
+        """
+        for level, threshold in LEVEL_THRESHOLDS_MS:
+            if total_ms >= threshold:
+                return level
+        return 0
     
     async def _generate_temp_username(self, session: AsyncSession) -> str:
         """
@@ -113,6 +131,7 @@ class UserService:
                 "username": username,
                 "user_type": "guest",
                 "bot_name": bot_name,
+                "user_level": 0,
                 "created_at": datetime.now().isoformat()
             }
             
@@ -265,7 +284,7 @@ class UserService:
             )
             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[Dict[str, int]]:
         """
         更新用户累积说话时长
         
@@ -274,7 +293,7 @@ class UserService:
             duration_ms: 本次说话时长(毫秒)
         
         Returns:
-            是否更新成功
+            包含 total_duration_ms 与 user_level 的字典,失败时返回 None
         """
         try:
             if duration_ms <= 0:
@@ -282,25 +301,41 @@ class UserService:
 
             async with db_manager.get_session() as session:
                 result = await session.execute(
-                    select(User.total_speech_duration_ms)
+                    select(User.total_speech_duration_ms, User.user_level)
                     .where(User.user_id == user_id)
                     .with_for_update()
                 )
-                current_total = result.scalar_one_or_none() or 0
+                row = result.one_or_none()
+                if row is None:
+                    logger.warning(f"⚠️ 更新说话时长失败,未找到用户: user_id={user_id}")
+                    return None
+
+                current_total = (row[0] or 0)
+                current_level = (row[1] or 0)
                 new_total = current_total + duration_ms
+                new_level = self._calculate_user_level(new_total)
+
+                update_payload: Dict[str, Any] = {
+                    "total_speech_duration_ms": new_total,
+                }
+                if new_level != current_level:
+                    update_payload["user_level"] = new_level
 
                 await session.execute(
                     update(User)
                     .where(User.user_id == user_id)
-                    .values(total_speech_duration_ms=new_total)
+                    .values(**update_payload)
                 )
                 await session.commit()
 
             logger.info(
                 f"✅ 更新用户说话时长成功: user_id={user_id}, "
-                f"duration_ms={duration_ms}, total_ms={new_total}"
+                f"duration_ms={duration_ms}, total_ms={new_total}, level={new_level}"
             )
-            return new_total
+            return {
+                "total_duration_ms": new_total,
+                "user_level": new_level,
+            }
         except Exception as e:
             logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
             return None

+ 25 - 3
backend/websocket_handler.py

@@ -27,6 +27,7 @@ from models import (
     PongMessage,
     EnergyStoneEventMessage,
     EnergyStoneSpawnMessage,
+    UserLevelMessage,
 )
 from ai_service import ai_service
 from request_logger import request_logger, PerformanceMetrics
@@ -217,6 +218,7 @@ class WebSocketHandler:
         
         speech_duration_ms = message.speechDuration or 0
         total_duration_ms = None
+        level_update = None
 
         # 🔥 更新用户累积说话时长(如果有提供,使用超时避免阻塞)
         if speech_duration_ms > 0:
@@ -224,7 +226,7 @@ class WebSocketHandler:
             try:
                 logger.info(f"[{request_id}] ⏱️ 开始更新用户说话时长: {speech_duration_ms}ms(超时: 1.0s)...")
                 # 使用超时,避免长时间阻塞
-                total_duration_ms = await asyncio.wait_for(
+                level_update = await asyncio.wait_for(
                     user_service.update_speech_duration(self.user_id, speech_duration_ms),
                     timeout=1.0  # 1秒超时
                 )
@@ -240,8 +242,17 @@ class WebSocketHandler:
         # 初始化AI响应收集器
         self.ai_responses[request_id] = []
 
-        if total_duration_ms:
-            await self._maybe_spawn_conversation_stones(total_duration_ms, speech_duration_ms, request_id)
+        if level_update and isinstance(level_update, dict):
+            total_duration_ms = level_update.get("total_duration_ms")
+            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)
+            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,
+                )
         
         # 检查是否取消
         if request_id in self.cancelled_requests:
@@ -976,6 +987,17 @@ class WebSocketHandler:
                 self.user_id,
                 exc,
             )
+
+    async def send_user_level_update(self, request_id: str, level: int, total_duration_ms: int):
+        """发送用户等级更新消息"""
+        message = UserLevelMessage(
+            type="user_level",
+            requestId=request_id,
+            timestamp=self._get_timestamp(),
+            level=level,
+            totalDurationMs=total_duration_ms,
+        )
+        await self.websocket.send_text(message.model_dump_json())
     
     async def send_text_result(self, request_id: str, text: str, is_final: bool):
         """发送文本结果"""

TEMPAT SAMPAH
frontend/src/assets/avatar-anima-zone copy.png


TEMPAT SAMPAH
frontend/src/assets/backbutn.png


TEMPAT SAMPAH
frontend/src/assets/share-btn.png


TEMPAT SAMPAH
frontend/src/assets/share-progress-bar.png


+ 79 - 18
frontend/src/pages/ChatBotPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import { v4 as uuidv4 } from 'uuid';
@@ -7,7 +7,16 @@ import { AudioRecorder, type FrequencyFrame } from '@/utils/audioRecorder';
 import { AudioPlayer } from '@/utils/audioPlayer';
 import { WebSocketManager } from '@/utils/websocket';
 import { prepareMobileAudio, requestVolumeControl, isMobileDevice } from '@/utils/audioHelper';
-import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '@/utils/storage';
+import {
+  setStorage,
+  getStorage,
+  getUserLevel,
+  STORAGE_KEYS,
+  markRouteVisited,
+  getUserId,
+  getVisitedRoutes,
+  setUserLevel,
+} from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
 import { getUserInfo, updateUserUIState, getUserUIState } from '@/utils/api';
@@ -93,6 +102,8 @@ export const ChatBotPage = () => {
   const sendTimeRef = useRef<number>(0);
   const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
   const audioWaveRef = useRef<AudioWaveHandle | null>(null);
+  const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
+  const levelRedirectRef = useRef(false);
   // 初始化时检查 localStorage,确保第一次打开时立即显示提示
   const [showTapToChat, setShowTapToChat] = useState(() => {
     const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
@@ -126,6 +137,13 @@ export const ChatBotPage = () => {
   useEffect(() => {
     activeRequestIdRef.current = currentRequestId;
   }, [currentRequestId]);
+  useEffect(() => {
+    const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
+    if (!isLoggedIn && userLevelValue >= 5 && !levelRedirectRef.current) {
+      levelRedirectRef.current = true;
+      navigate('/level5-welcome');
+    }
+  }, [userLevelValue, navigate]);
   const [showCancelTooltip, setShowCancelTooltip] = useState(false);
   const [showSendTooltip, setShowSendTooltip] = useState(false);
   const [showProgressBarTooltip, setShowProgressBarTooltip] = useState(false);
@@ -184,6 +202,27 @@ export const ChatBotPage = () => {
   const [currentAvatarGif, setCurrentAvatarGif] = useState<string>(DEFAULT_AVATAR_GIF);
   // GIF 切换定时器
   const emotionGifTimerRef = useRef<number | null>(null);
+  const handleAvatarClick = useCallback(() => {
+    const isLoggedIn = getStorage(STORAGE_KEYS.IS_LOGGED_IN) === 'true';
+    if (isLoggedIn || !isAvatarCUnlocked) {
+      return;
+    }
+    if (userLevelValue >= 5) {
+      levelRedirectRef.current = true;
+      navigate('/level5-welcome');
+      return;
+    }
+    navigate('/level34-welcome');
+  }, [isAvatarCUnlocked, navigate, userLevelValue]);
+  const handleAvatarKeyDown = useCallback(
+    (event: KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        handleAvatarClick();
+      }
+    },
+    [handleAvatarClick],
+  );
   
   const timingRef = useRef({
     recordStart: 0,
@@ -191,6 +230,11 @@ export const ChatBotPage = () => {
     firstAudioReceived: 0,
     emotionReceived: 0,
   });
+  const syncUserLevel = useCallback((level: number) => {
+    setUserLevel(level);
+    setUserLevelValue(level);
+  }, []);
+
   const unlockAvatarC = useCallback(() => {
     if (avatarCUnlockedRef.current) {
       return;
@@ -638,22 +682,27 @@ export const ChatBotPage = () => {
         if (user_id) {
           try {
             const userInfo = await getUserInfo(user_id);
-            if (userInfo.success && userInfo.data.total_speech_duration_ms) {
-              // 根据总说话时长计算初始进度(30秒=100%)
-              const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
-              const totalDurationMs = userInfo.data.total_speech_duration_ms || 0;
-              totalSpeechDurationRef.current = Math.max(
-                totalDurationMs,
-                avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
-              );
-              if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
-                unlockAvatarC();
+            if (userInfo.success && userInfo.data) {
+              const totalDurationMs = userInfo.data.total_speech_duration_ms ?? 0;
+              if (typeof userInfo.data.user_level === 'number') {
+                syncUserLevel(userInfo.data.user_level);
+              }
+              if (totalDurationMs > 0) {
+                // 根据总说话时长计算初始进度(30秒=100%)
+                const PROGRESS_FULL_DURATION_MS = 30 * 1000; // 30000ms
+                totalSpeechDurationRef.current = Math.max(
+                  totalDurationMs,
+                  avatarCUnlockedRef.current ? AVATAR_C_UNLOCK_THRESHOLD_MS : 0
+                );
+                if (totalDurationMs >= AVATAR_C_UNLOCK_THRESHOLD_MS) {
+                  unlockAvatarC();
+                }
+                const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
+                const initialLoopCount = Math.floor(totalProgressPercent / 100);
+                const initialProgress = totalProgressPercent % 100;
+                shouldSkipProgressAnimationRef.current = true;
+                setProgress(initialProgress, initialLoopCount);
               }
-              const totalProgressPercent = (totalDurationMs / PROGRESS_FULL_DURATION_MS) * 100;
-              const initialLoopCount = Math.floor(totalProgressPercent / 100);
-              const initialProgress = totalProgressPercent % 100;
-              shouldSkipProgressAnimationRef.current = true;
-              setProgress(initialProgress, initialLoopCount);
             }
           } catch (error) {
             // 获取用户信息失败不影响主流程,静默处理
@@ -809,6 +858,11 @@ export const ChatBotPage = () => {
         }
         setRecognizedText(message.text);
         break;
+      case 'user_level':
+        if (typeof message.level === 'number') {
+          syncUserLevel(message.level);
+        }
+        break;
 
       case 'emotion':
         // 如果当前状态是 recording,且 requestId 不匹配当前请求,说明这是旧请求的返回,直接丢弃
@@ -1247,14 +1301,21 @@ export const ChatBotPage = () => {
 
       {/* Avatar-C 头像 - 设计稿:top 63px, right 33px, 尺寸 40×40,使用 px2vmin 等比缩放 */}
       <div
-        className="chatbot-avatar-c absolute pointer-events-none"
+        className="chatbot-avatar-c absolute"
         data-avatar-state={isAvatarCUnlocked ? 'unlocked' : 'locked'}
+        role={isAvatarCUnlocked ? 'button' : undefined}
+        tabIndex={isAvatarCUnlocked ? 0 : -1}
+        aria-label={isAvatarCUnlocked ? 'Avatar C' : undefined}
+        onClick={isAvatarCUnlocked ? handleAvatarClick : undefined}
+        onKeyDown={isAvatarCUnlocked ? handleAvatarKeyDown : undefined}
         style={{
           width: px2vmin(40),
           height: px2vmin(40),
           top: px2vmin(63),
           right: px2vmin(33),
           zIndex: isAvatarCUnlocked ? 30 : 5,
+          pointerEvents: isAvatarCUnlocked ? 'auto' : 'none',
+          cursor: isAvatarCUnlocked ? 'pointer' : 'default',
         }}
       >
         {isAvatarCUnlocked && (

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

@@ -1,7 +1,17 @@
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
-import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes, setUserId, setUserName } from '../utils/storage';
+import {
+  getStorage,
+  setStorage,
+  STORAGE_KEYS,
+  markRouteVisited,
+  getUserId,
+  getVisitedRoutes,
+  setUserId,
+  setUserName,
+  setUserLevel,
+} from '../utils/storage';
 import { getUserInfo, updateUsername, updateUserUIState, createUser } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -42,6 +52,9 @@ export const GreetingPage = () => {
             if (response.data.username) {
               setUserName(response.data.username);
             }
+            if (typeof response.data.user_level === 'number') {
+              setUserLevel(response.data.user_level);
+            }
           } else {
             throw new Error('创建用户失败:响应数据无效');
           }
@@ -79,6 +92,9 @@ export const GreetingPage = () => {
             
             if (response.success && response.data) {
               const { username, bot_name } = response.data;
+              if (typeof response.data.user_level === 'number') {
+                setUserLevel(response.data.user_level);
+              }
               
               // 将 username 存储到 Lyrria_user_name
               if (username) {

+ 326 - 0
frontend/src/pages/Level34WelcomePage.tsx

@@ -0,0 +1,326 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
+import { updateUserUIState } from '@/utils/api';
+import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
+import giftProgressBar from '@/assets/gift-progress-bar.png';
+
+export const Level34WelcomePage = () => {
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    // 记录用户当前所在页面
+    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'level34-welcome');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'level34-welcome',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+  }, []);
+
+  useEffect(() => {
+    // 文案动画与过渡结束后,跳转到第一次登录界面
+    const timer = window.setTimeout(() => {
+      navigate('/first-login');
+    }, 8400);
+
+    return () => {
+      window.clearTimeout(timer);
+    };
+  }, [navigate]);
+
+  return (
+    <div>
+      {/* 进度条图片 - Y: 64, 左右居中, width: 195, height: 32,动画结束后显示 */}
+      <img
+        src={giftProgressBar}
+        alt="gift progress bar"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(64),
+          width: px2vmin(195),
+          // height: px2vmin(32),
+          transform: 'translateX(-50%)',
+          opacity: 0,
+          animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
+        }}
+      />
+      {/* GIF动画图片 - Y: 192, 左右居中, width: 343, height: 280,动画结束后显示 */}
+      <img
+        src="https://tolan.oss-cn-hangzhou.aliyuncs.com/03-Cheer.gif"
+        alt="cheer animation"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(192),
+          width: px2vmin(343),
+          // height: px2vmin(280),
+          transform: 'translateX(-50%)',
+          opacity: 0,
+          animation: 'imageSlideDownAndShake 1s ease-out 2s forwards',
+        }}
+      />
+      {/* 文字 - Congratulation! - Y: 504, 左右居中, 24px加粗白色,动画结束后显示,5.4秒后消失 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(567),
+          transform: 'translateX(-50%)',
+          color: 'white',
+          fontSize: px2vmin(24),
+          fontWeight: 'bold',
+          whiteSpace: 'nowrap',
+          opacity: 0,
+          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+        }}
+      >
+        Congratulation!
+      </div>
+      {/* 文字 - 恭喜你成功升到5级!... - Y: 615, 左右居中, 16px白色,动画结束后显示,5.4秒后消失 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(615),
+          transform: 'translateX(-50%)',
+          color: 'white',
+          fontSize: px2vmin(14),
+          textAlign: 'center',
+          width: px2vmin(270),
+          opacity: 0,
+          animation: 'textSlideUpAndShakeAndFadeOut 6.4s ease-out 2s forwards',
+        }}
+      >
+        恭喜你成功升到4级!能坚持到3-4级真的很棒,登录后有惊喜礼物等你领取哦
+      </div>
+      {/* 扩展圆形动画 - 设计稿:圆心(323, 147),最大半径236 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: px2vw(323),
+          top: px2vh(147),
+          width: 0,
+          height: 0,
+          borderRadius: '50%',
+          background: 'rgb(56, 54, 67)',
+          transform: 'translate(-50%, -50%)',
+          animation: 'circleExpand 1.5s ease-out forwards',
+        }}
+      />
+      {/* 第二个圆 - 圆心(-26.5, 26.5),半径332.5,从圆心扩展 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: px2vw(-26.5),
+          top: px2vh(26.5),
+          width: 0,
+          height: 0,
+          borderRadius: '50%',
+          background: 'rgb(56, 54, 67)',
+          transform: 'translate(-50%, -50%)',
+          animation: 'circleExpand2 1.5s ease-out forwards',
+        }}
+      />
+      {/* 第三个圆 - 圆心(123, 572),半径340,从圆心扩展 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: px2vw(123),
+          top: px2vh(572),
+          width: 0,
+          height: 0,
+          borderRadius: '50%',
+          background: 'rgb(56, 54, 67)',
+          transform: 'translate(-50%, -50%)',
+          animation: 'circleExpand3 1.5s ease-out forwards',
+        }}
+      />
+      {/* 第四个圆 - 圆心(398.5, 829.5),半径258.5,从圆心扩展 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: px2vw(398.5),
+          top: px2vh(829.5),
+          width: 0,
+          height: 0,
+          borderRadius: '50%',
+          background: 'rgb(56, 54, 67)',
+          transform: 'translate(-50%, -50%)',
+          animation: 'circleExpand4 1.5s ease-out forwards',
+        }}
+      />
+      <style>{`
+        @keyframes circleExpand {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          99% {
+            width: ${px2vmin(236 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(236 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(236 * 2)};
+            height: ${px2vmin(236 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand2 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          99% {
+            width: ${px2vmin(332.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(332.5 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(332.5 * 2)};
+            height: ${px2vmin(332.5 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand3 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          99% {
+            width: ${px2vmin(340 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(340 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(340 * 2)};
+            height: ${px2vmin(340 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand4 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          99% {
+            width: ${px2vmin(258.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(258.5 * 2)};
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(258.5 * 2)};
+            height: ${px2vmin(258.5 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes imageFadeIn {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 1;
+          }
+        }
+        @keyframes imageSlideDownAndShake {
+          0% {
+            transform: translateX(-50%) translateY(-192px);
+            opacity: 0;
+          }
+          60% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+          70% {
+            transform: translateX(-50%) translateY(-5px);
+          }
+          80% {
+            transform: translateX(-50%) translateY(5px);
+          }
+          90% {
+            transform: translateX(-50%) translateY(-3px);
+          }
+          100% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+        }
+        @keyframes textSlideUpAndShake {
+          0% {
+            transform: translateX(-50%) translateY(100px);
+            opacity: 0;
+          }
+          60% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+          70% {
+            transform: translateX(-50%) translateY(-5px);
+          }
+          80% {
+            transform: translateX(-50%) translateY(5px);
+          }
+          90% {
+            transform: translateX(-50%) translateY(-3px);
+          }
+          100% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+        }
+        @keyframes textSlideUpAndShakeAndFadeOut {
+          0% {
+            transform: translateX(-50%) translateY(100px);
+            opacity: 0;
+          }
+          15.625% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+          18.75% {
+            transform: translateX(-50%) translateY(-5px);
+          }
+          21.875% {
+            transform: translateX(-50%) translateY(5px);
+          }
+          25% {
+            transform: translateX(-50%) translateY(-3px);
+          }
+          28.125% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+          99.5% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 1;
+          }
+          100% {
+            transform: translateX(-50%) translateY(0);
+            opacity: 0;
+          }
+        }
+        @keyframes fadeIn {
+          0% {
+            opacity: 0;
+          }
+          100% {
+            opacity: 1;
+          }
+        }
+      `}</style>
+    </div>
+  );
+};
+

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

@@ -0,0 +1,138 @@
+import { useEffect } from 'react';
+import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
+import { updateUserUIState } from '@/utils/api';
+import { px2vmin } from '@/utils/viewport';
+import wechatLogin from '@/assets/wechat-login.png';
+import qqLogin from '@/assets/qq-login.png';
+import phoneLogin from '@/assets/phone-login.png';
+import emailLogin from '@/assets/email-login.png';
+
+export const LoginPage = () => {
+  useEffect(() => {
+    // 记录用户当前所在页面
+    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'login');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'login',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+  }, []);
+
+  return (
+    <div>
+      {/* 欢迎文案 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(112),
+          width: px2vmin(270),
+          transform: 'translateX(-50%)',
+          fontSize: px2vmin(26),
+          textAlign: 'center',
+          lineHeight: 1.4,
+          whiteSpace: 'normal',
+          background: 'linear-gradient(97.3deg, rgba(245, 243, 240, 0.8) 2.21%, rgba(245, 243, 240, 0.3) 150.89%)',
+          WebkitBackgroundClip: 'text',
+          WebkitTextFillColor: 'transparent',
+          backgroundClip: 'text',
+        }}
+      >
+        Welcome back! It&rsquo;s time to chat with{' '}
+        <span
+          style={{
+            background: 'none',
+            WebkitBackgroundClip: 'initial',
+            WebkitTextFillColor: '#BF9DF8',
+            color: '#BF9DF8',
+          }}
+        >
+          Lumi
+        </span>{' '}
+        again
+      </div>
+      {/* 中部 GIF 动画图片 */}
+      <img
+        src="https://tolan.oss-cn-hangzhou.aliyuncs.com/01-Default.gif"
+        alt="cheer animation"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(192),
+          width: px2vmin(343),
+          transform: 'translateX(-50%)',
+        }}
+      />
+      {/* 微信登录按钮 */}
+      <img
+        src={wechatLogin}
+        alt="wechat login"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(567),
+          width: px2vmin(279),
+          transform: 'translateX(-50%)',
+        }}
+      />
+      {/* 其他登录方式文字 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(687),
+          transform: 'translateX(-50%)',
+          color: '#F5F3F0',
+          fontSize: px2vmin(12),
+          whiteSpace: 'nowrap',
+        }}
+      >
+        —— 其他登陆方式 ——
+      </div>
+      {/* 按钮行:QQ / 手机 / 邮箱 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(715),
+          transform: 'translateX(-50%)',
+          display: 'flex',
+          gap: px2vmin(32),
+          alignItems: 'center',
+          justifyContent: 'center',
+          width: 'fit-content',
+        }}
+      >
+        <img
+          src={qqLogin}
+          alt="qq login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+        <img
+          src={phoneLogin}
+          alt="phone login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+        <img
+          src={emailLogin}
+          alt="email login"
+          style={{
+            height: px2vmin(48),
+          }}
+        />
+      </div>
+    </div>
+  );
+};
+
+

+ 8 - 0
frontend/src/pages/WelcomePage.css

@@ -435,9 +435,17 @@ video::-webkit-media-controls-overlay-play-button {
   color: rgba(245, 243, 240, 1); /* 白色文字,与协议文字一致 */
   text-decoration: underline;
   user-select: none;
+  cursor: pointer;
+  transition: opacity 0.2s ease;
+  -webkit-tap-highlight-color: transparent;
   z-index: 2;
 }
 
+.agreement-login-tip:active,
+.agreement-login-tip:hover {
+  opacity: 0.8;
+}
+
 .agreement-content {
   display: flex;
   align-items: center;

+ 20 - 2
frontend/src/pages/WelcomePage.tsx

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import lumiImage from '@/assets/Lumi-image.png';
@@ -35,6 +35,11 @@ export const WelcomePage = () => {
     // 记录用户当前所在页面
     const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'welcome');
     console.log('[WelcomePage] set CURRENT_PAGE=welcome result:', ok);
+    if (!getStorage(STORAGE_KEYS.USER_LEVEL)) {
+      setStorage(STORAGE_KEYS.USER_LEVEL, '0');
+    }
+    // 默认标记为未登录
+    setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
     
     // 记录路由访问
     markRouteVisited('/welcome');
@@ -600,7 +605,20 @@ export const WelcomePage = () => {
           </div>
           
           {/* 已有账号提示 + 协议文字 */}
-          <div className="agreement-login-tip">已有账号,请登录</div>
+          <div
+            className="agreement-login-tip"
+            role="button"
+            tabIndex={0}
+            onClick={() => navigate('/login')}
+            onKeyDown={(event) => {
+              if (event.key === 'Enter' || event.key === ' ') {
+                event.preventDefault();
+                navigate('/login');
+              }
+            }}
+          >
+            已有账号,请登录
+          </div>
           <div className="agreement-container">
             <div className="agreement-content">
               <div

+ 2 - 0
frontend/src/pages/index.ts

@@ -4,4 +4,6 @@ export { GreetingPage } from './GreetingPage';
 export { ChatBotPage } from './ChatBotPage';
 export { RegisterPage } from './RegisterPage';
 export { SharePage } from './SharePage';
+export { Level5WelcomePage } from './Level5WelcomePage';
+export { Level34WelcomePage } from './Level34WelcomePage';
 

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

@@ -8,7 +8,9 @@ import { ChatBotPage } from '@/pages/ChatBotPage';
 import { RegisterPage } from '@/pages/RegisterPage';
 import { SharePage } from '@/pages/SharePage';
 import { Level5WelcomePage } from '@/pages/Level5WelcomePage';
+import { Level34WelcomePage } from '@/pages/Level34WelcomePage';
 import { FirstLoginPage } from '@/pages/FirstLoginPage';
+import { LoginPage } from '@/pages/LoginPage';
 import { UserGuard } from '@/components/UserGuard';
 
 const basename = import.meta.env.BASE_URL || '/';
@@ -50,10 +52,18 @@ export const router = createBrowserRouter([
         path: '/level5-welcome',
         element: <Level5WelcomePage />,
       },
+      {
+        path: '/level34-welcome',
+        element: <Level34WelcomePage />,
+      },
       {
         path: '/first-login',
         element: <FirstLoginPage />,
       },
+      {
+        path: '/login',
+        element: <LoginPage />,
+      },
       {
         path: '*',
         element: <Navigate to="/" replace />,

+ 10 - 2
frontend/src/types/index.ts

@@ -10,7 +10,8 @@ export type MessageType =
   | 'ping'
   | 'pong'
   | 'energy_stone_spawn'
-  | 'energy_stone_event';
+  | 'energy_stone_event'
+  | 'user_level';
 
 // 基础动画状态
 export type AnimationState = 
@@ -147,6 +148,12 @@ export interface EnergyStoneEventMessage extends BaseMessage {
   };
 }
 
+export interface UserLevelMessage extends BaseMessage {
+  type: 'user_level';
+  level: number;
+  totalDurationMs: number;
+}
+
 // 所有消息类型联合
 export type Message =
   | AudioChunkMessage
@@ -159,5 +166,6 @@ export type Message =
   | PingMessage
   | PongMessage
   | EnergyStoneSpawnMessage
-  | EnergyStoneEventMessage;
+  | EnergyStoneEventMessage
+  | UserLevelMessage;
 

+ 8 - 0
frontend/src/utils/api.ts

@@ -40,6 +40,7 @@ export interface CreateUserResponse {
     user_id: string;
     username: string;
     user_type: string;
+    user_level: number;
     created_at: string;
   };
 }
@@ -105,6 +106,7 @@ export interface GetUserResponse {
     user_type: string;
     bot_name?: string;
     total_speech_duration_ms?: number;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -255,6 +257,7 @@ export interface RegisterPhoneResponse {
     username: string;
     phone: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -285,6 +288,7 @@ export interface RegisterEmailResponse {
     username: string;
     email: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -315,6 +319,7 @@ export interface LoginWechatResponse {
     username: string;
     wechat_openid?: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -345,6 +350,7 @@ export interface LoginQQResponse {
     username: string;
     qq_openid?: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -375,6 +381,7 @@ export interface LoginPhoneResponse {
     username: string;
     phone: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }
@@ -405,6 +412,7 @@ export interface LoginEmailResponse {
     username: string;
     email: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
   };
 }

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

@@ -17,6 +17,8 @@ export const STORAGE_KEYS = {
   HAS_SHOWN_MISSION_STONE_MODAL: 'Lyrria_has_shown_mission_stone_modal', // 是否已显示过任务能量石弹窗
   HAS_SHOWN_CONVERSATION_STONE_MODAL: 'Lyrria_has_shown_conversation_stone_modal', // 是否已显示过对话能量石弹窗
   AVATAR_C_UNLOCKED: 'Lyrria_avatar_c_unlocked', // 是否已永久展示 Avatar-C
+  IS_LOGGED_IN: 'Lyrria_is_logged_in', // 是否已登录
+  USER_LEVEL: 'Lyrria_user_level', // 用户当前等级
 } as const;
 
 // 路由顺序表
@@ -216,6 +218,22 @@ export const clearUserName = (): boolean => {
   return removeStorage(STORAGE_KEYS.USER_NAME);
 };
 
+/**
+ * 获取用户等级
+ */
+export const getUserLevel = (): number => {
+  const value = getStorage(STORAGE_KEYS.USER_LEVEL);
+  const parsed = Number(value);
+  return Number.isFinite(parsed) ? parsed : 0;
+};
+
+/**
+ * 设置用户等级
+ */
+export const setUserLevel = (level: number): boolean => {
+  return setStorage(STORAGE_KEYS.USER_LEVEL, String(level));
+};
+
 /**
  * 清除用户相关的路由和页面状态
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page