zlwu 11 месяцев назад
Родитель
Сommit
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",
     "pong",
     "energy_stone_spawn",
     "energy_stone_spawn",
     "energy_stone_event",
     "energy_stone_event",
+    "user_level",
 ]
 ]
 
 
 # 情感类型
 # 情感类型
@@ -145,3 +146,10 @@ class EnergyStoneEventMessage(BaseMessage):
     clientTs: Optional[int] = None
     clientTs: Optional[int] = None
     position: Optional[Dict] = 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_messages = Column(Integer, default=0, comment="总消息数")
     total_rounds = Column(Integer, default=0, comment="总对话轮次")
     total_rounds = Column(Integer, default=0, comment="总对话轮次")
     total_speech_duration_ms = Column(BigInteger, 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="最后活跃时间")
     last_active_at = Column(TIMESTAMP, nullable=True, comment="最后活跃时间")
     
     
     created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
     created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
@@ -75,6 +76,7 @@ class User(Base):
             "total_messages": self.total_messages,
             "total_messages": self.total_messages,
             "total_rounds": self.total_rounds,
             "total_rounds": self.total_rounds,
             "total_speech_duration_ms": self.total_speech_duration_ms,
             "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,
             "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,
             "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 random
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict, Optional, Any
+from typing import Dict, Optional, Any, Tuple
 from datetime import datetime
 from datetime import datetime
 from sqlalchemy import select, update, func, insert
 from sqlalchemy import select, update, func, insert
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,9 +14,27 @@ from models_db import User, UserUIState
 
 
 logger = logging.getLogger(__name__)
 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:
 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:
     async def _generate_temp_username(self, session: AsyncSession) -> str:
         """
         """
@@ -113,6 +131,7 @@ class UserService:
                 "username": username,
                 "username": username,
                 "user_type": "guest",
                 "user_type": "guest",
                 "bot_name": bot_name,
                 "bot_name": bot_name,
+                "user_level": 0,
                 "created_at": datetime.now().isoformat()
                 "created_at": datetime.now().isoformat()
             }
             }
             
             
@@ -265,7 +284,7 @@ class UserService:
             )
             )
             return False
             return False
     
     
-    async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[int]:
+    async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[Dict[str, int]]:
         """
         """
         更新用户累积说话时长
         更新用户累积说话时长
         
         
@@ -274,7 +293,7 @@ class UserService:
             duration_ms: 本次说话时长(毫秒)
             duration_ms: 本次说话时长(毫秒)
         
         
         Returns:
         Returns:
-            是否更新成功
+            包含 total_duration_ms 与 user_level 的字典,失败时返回 None
         """
         """
         try:
         try:
             if duration_ms <= 0:
             if duration_ms <= 0:
@@ -282,25 +301,41 @@ class UserService:
 
 
             async with db_manager.get_session() as session:
             async with db_manager.get_session() as session:
                 result = await session.execute(
                 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)
                     .where(User.user_id == user_id)
                     .with_for_update()
                     .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_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(
                 await session.execute(
                     update(User)
                     update(User)
                     .where(User.user_id == user_id)
                     .where(User.user_id == user_id)
-                    .values(total_speech_duration_ms=new_total)
+                    .values(**update_payload)
                 )
                 )
                 await session.commit()
                 await session.commit()
 
 
             logger.info(
             logger.info(
                 f"✅ 更新用户说话时长成功: user_id={user_id}, "
                 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:
         except Exception as e:
             logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
             logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
             return None
             return None

+ 25 - 3
backend/websocket_handler.py

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

BIN
frontend/src/assets/avatar-anima-zone copy.png


BIN
frontend/src/assets/backbutn.png


BIN
frontend/src/assets/share-btn.png


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

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

@@ -1,7 +1,17 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 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 { getUserInfo, updateUsername, updateUserUIState, createUser } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -42,6 +52,9 @@ export const GreetingPage = () => {
             if (response.data.username) {
             if (response.data.username) {
               setUserName(response.data.username);
               setUserName(response.data.username);
             }
             }
+            if (typeof response.data.user_level === 'number') {
+              setUserLevel(response.data.user_level);
+            }
           } else {
           } else {
             throw new Error('创建用户失败:响应数据无效');
             throw new Error('创建用户失败:响应数据无效');
           }
           }
@@ -79,6 +92,9 @@ export const GreetingPage = () => {
             
             
             if (response.success && response.data) {
             if (response.success && response.data) {
               const { username, bot_name } = response.data;
               const { username, bot_name } = response.data;
+              if (typeof response.data.user_level === 'number') {
+                setUserLevel(response.data.user_level);
+              }
               
               
               // 将 username 存储到 Lyrria_user_name
               // 将 username 存储到 Lyrria_user_name
               if (username) {
               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); /* 白色文字,与协议文字一致 */
   color: rgba(245, 243, 240, 1); /* 白色文字,与协议文字一致 */
   text-decoration: underline;
   text-decoration: underline;
   user-select: none;
   user-select: none;
+  cursor: pointer;
+  transition: opacity 0.2s ease;
+  -webkit-tap-highlight-color: transparent;
   z-index: 2;
   z-index: 2;
 }
 }
 
 
+.agreement-login-tip:active,
+.agreement-login-tip:hover {
+  opacity: 0.8;
+}
+
 .agreement-content {
 .agreement-content {
   display: flex;
   display: flex;
   align-items: center;
   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 { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
 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 { updateUserUIState } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import 'video.js/dist/video-js.css';
 import lumiImage from '@/assets/Lumi-image.png';
 import lumiImage from '@/assets/Lumi-image.png';
@@ -35,6 +35,11 @@ export const WelcomePage = () => {
     // 记录用户当前所在页面
     // 记录用户当前所在页面
     const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'welcome');
     const ok = setStorage(STORAGE_KEYS.CURRENT_PAGE, 'welcome');
     console.log('[WelcomePage] set CURRENT_PAGE=welcome result:', ok);
     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');
     markRouteVisited('/welcome');
@@ -600,7 +605,20 @@ export const WelcomePage = () => {
           </div>
           </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-container">
             <div className="agreement-content">
             <div className="agreement-content">
               <div
               <div

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

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

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

@@ -10,7 +10,8 @@ export type MessageType =
   | 'ping'
   | 'ping'
   | 'pong'
   | 'pong'
   | 'energy_stone_spawn'
   | 'energy_stone_spawn'
-  | 'energy_stone_event';
+  | 'energy_stone_event'
+  | 'user_level';
 
 
 // 基础动画状态
 // 基础动画状态
 export type AnimationState = 
 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 =
 export type Message =
   | AudioChunkMessage
   | AudioChunkMessage
@@ -159,5 +166,6 @@ export type Message =
   | PingMessage
   | PingMessage
   | PongMessage
   | PongMessage
   | EnergyStoneSpawnMessage
   | EnergyStoneSpawnMessage
-  | EnergyStoneEventMessage;
+  | EnergyStoneEventMessage
+  | UserLevelMessage;
 
 

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

@@ -40,6 +40,7 @@ export interface CreateUserResponse {
     user_id: string;
     user_id: string;
     username: string;
     username: string;
     user_type: string;
     user_type: string;
+    user_level: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -105,6 +106,7 @@ export interface GetUserResponse {
     user_type: string;
     user_type: string;
     bot_name?: string;
     bot_name?: string;
     total_speech_duration_ms?: number;
     total_speech_duration_ms?: number;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -255,6 +257,7 @@ export interface RegisterPhoneResponse {
     username: string;
     username: string;
     phone: string;
     phone: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -285,6 +288,7 @@ export interface RegisterEmailResponse {
     username: string;
     username: string;
     email: string;
     email: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -315,6 +319,7 @@ export interface LoginWechatResponse {
     username: string;
     username: string;
     wechat_openid?: string;
     wechat_openid?: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -345,6 +350,7 @@ export interface LoginQQResponse {
     username: string;
     username: string;
     qq_openid?: string;
     qq_openid?: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -375,6 +381,7 @@ export interface LoginPhoneResponse {
     username: string;
     username: string;
     phone: string;
     phone: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     created_at: string;
   };
   };
 }
 }
@@ -405,6 +412,7 @@ export interface LoginEmailResponse {
     username: string;
     username: string;
     email: string;
     email: string;
     user_type: string;
     user_type: string;
+    user_level?: number;
     created_at: string;
     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_MISSION_STONE_MODAL: 'Lyrria_has_shown_mission_stone_modal', // 是否已显示过任务能量石弹窗
   HAS_SHOWN_CONVERSATION_STONE_MODAL: 'Lyrria_has_shown_conversation_stone_modal', // 是否已显示过对话能量石弹窗
   HAS_SHOWN_CONVERSATION_STONE_MODAL: 'Lyrria_has_shown_conversation_stone_modal', // 是否已显示过对话能量石弹窗
   AVATAR_C_UNLOCKED: 'Lyrria_avatar_c_unlocked', // 是否已永久展示 Avatar-C
   AVATAR_C_UNLOCKED: 'Lyrria_avatar_c_unlocked', // 是否已永久展示 Avatar-C
+  IS_LOGGED_IN: 'Lyrria_is_logged_in', // 是否已登录
+  USER_LEVEL: 'Lyrria_user_level', // 用户当前等级
 } as const;
 } as const;
 
 
 // 路由顺序表
 // 路由顺序表
@@ -216,6 +218,22 @@ export const clearUserName = (): boolean => {
   return removeStorage(STORAGE_KEYS.USER_NAME);
   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
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page