zlwu 11 ماه پیش
والد
کامیت
cbedba982f

+ 74 - 1
backend/main.py

@@ -5,7 +5,7 @@ import json
 import logging
 import urllib.parse
 import hashlib
-from typing import Optional
+from typing import Optional, List, Dict, Any
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query
 from fastapi.responses import RedirectResponse, PlainTextResponse
 from fastapi.middleware.cors import CORSMiddleware
@@ -140,6 +140,31 @@ class LoginEmailRequest(BaseModel):
     email: str
 
 
+class UserUIStateResponse(BaseModel):
+    """用户前端可视化状态"""
+
+    user_id: str
+    current_page: Optional[str] = None
+    visited_routes: List[str] = []
+    has_shown_tap_to_chat: bool = False
+    has_shown_cancel_tooltip: bool = False
+    has_shown_send_tooltip: bool = False
+    has_shown_progress_bar_tooltip: bool = False
+    avatar_c_unlocked: bool = False
+
+
+class UpdateUserUIStateRequest(BaseModel):
+    """部分更新用户前端可视化状态"""
+
+    current_page: Optional[str] = None
+    visited_routes: Optional[List[str]] = None
+    has_shown_tap_to_chat: Optional[bool] = None
+    has_shown_cancel_tooltip: Optional[bool] = None
+    has_shown_send_tooltip: Optional[bool] = None
+    has_shown_progress_bar_tooltip: Optional[bool] = None
+    avatar_c_unlocked: Optional[bool] = None
+
+
 @app.post("/api/users/create")
 async def create_user(request: CreateUserRequest):
     """
@@ -470,6 +495,54 @@ async def login_with_email(request: LoginEmailRequest):
         raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
 
 
+@app.get("/api/users/{user_id}/ui-state")
+async def get_user_ui_state(user_id: str) -> Dict[str, Any]:
+    """
+    获取用户前端可视化状态(页面、路由、提示状态)
+    """
+    try:
+        state = await user_service.get_user_ui_state(user_id)
+        if not state:
+            # 没有记录时返回默认值
+            default_state = UserUIStateResponse(
+                user_id=user_id,
+                current_page=None,
+                visited_routes=[],
+            )
+            return {"success": True, "data": default_state.dict()}
+        return {"success": True, "data": state}
+    except Exception as e:  # noqa: BLE001
+        raise HTTPException(status_code=500, detail=f"获取用户UI状态失败: {str(e)}") from e
+
+
+@app.put("/api/users/{user_id}/ui-state")
+async def update_user_ui_state(
+    user_id: str,
+    request: UpdateUserUIStateRequest,
+) -> Dict[str, Any]:
+    """
+    更新用户前端可视化状态(部分字段)
+    """
+    try:
+        updated = await user_service.upsert_user_ui_state(
+            user_id=user_id,
+            current_page=request.current_page,
+            visited_routes=request.visited_routes,
+            has_shown_tap_to_chat=request.has_shown_tap_to_chat,
+            has_shown_cancel_tooltip=request.has_shown_cancel_tooltip,
+            has_shown_send_tooltip=request.has_shown_send_tooltip,
+            has_shown_progress_bar_tooltip=request.has_shown_progress_bar_tooltip,
+            avatar_c_unlocked=request.avatar_c_unlocked,
+        )
+        if not updated:
+            raise HTTPException(status_code=500, detail="更新用户UI状态失败")
+        return {"success": True, "data": updated}
+    except HTTPException:
+        raise
+    except Exception as e:  # noqa: BLE001
+        raise HTTPException(status_code=500, detail=f"更新用户UI状态失败: {str(e)}") from e
+
+
 # ==================== OAuth 授权端点 ====================
 
 @app.get("/api/auth/wechat/authorize")

+ 89 - 0
backend/migrate_add_user_ui_state_table.py

@@ -0,0 +1,89 @@
+"""
+数据库迁移脚本:创建 user_ui_states 表
+运行: python migrate_add_user_ui_state_table.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_user_ui_states_table() -> bool:
+    """创建 user_ui_states 表(如果不存在)"""
+    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.tables
+                WHERE table_schema = %s AND table_name = 'user_ui_states'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            table_exists = bool(exists_row and exists_row[0] > 0)
+
+            if table_exists:
+                print("✅ user_ui_states 表已存在,无需迁移")
+                await conn.ensure_closed()
+                return True
+
+            print("🔄 创建 user_ui_states 表...")
+            await cursor.execute(
+                """
+                CREATE TABLE user_ui_states (
+                    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                    user_id VARCHAR(64) NOT NULL UNIQUE COMMENT '用户ID(关联 users.user_id)',
+                    current_page VARCHAR(64) NULL COMMENT '当前业务流程页面标识',
+                    visited_routes JSON NULL COMMENT '已访问路由列表',
+                    has_shown_tap_to_chat TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示 TapToChat 提示',
+                    has_shown_cancel_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示取消按钮提示',
+                    has_shown_send_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示发送按钮提示',
+                    has_shown_progress_bar_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示进度条提示',
+                    avatar_c_unlocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已解锁 Avatar-C',
+                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                    INDEX idx_user_ui_state_user_id (user_id)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户前端可视化状态表';
+                """
+            )
+
+            await conn.commit()
+            print("✅ user_ui_states 表创建完成")
+
+        await conn.ensure_closed()
+        return True
+    except Exception as exc:  # noqa: BLE001
+        import traceback
+
+        print(f"❌ 迁移失败: {exc}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续创建 user_ui_states 表? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_user_ui_states_table())
+    if not ok:
+        sys.exit(1)
+
+

+ 55 - 2
backend/models_db.py

@@ -4,8 +4,17 @@ SQLAlchemy 数据库模型定义
 from datetime import datetime
 from typing import Optional
 from sqlalchemy import (
-    Column, String, Integer, BigInteger, Text,
-    Enum, Boolean, JSON, TIMESTAMP, Index, UniqueConstraint
+    Column,
+    String,
+    Integer,
+    BigInteger,
+    Text,
+    Enum,
+    Boolean,
+    JSON,
+    TIMESTAMP,
+    Index,
+    UniqueConstraint,
 )
 from sqlalchemy.orm import declarative_base
 from sqlalchemy.sql import func
@@ -237,6 +246,50 @@ class ConversationEnergyStone(Base):
     )
 
 
+class UserUIState(Base):
+    """用户前端可视化状态表(页面与提示状态)"""
+
+    __tablename__ = "user_ui_states"
+
+    id = Column(PKBigInt, primary_key=True, autoincrement=True)
+    user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户ID(关联 users.user_id)")
+
+    # 页面与路由状态
+    current_page = Column(String(64), nullable=True, comment="当前业务流程页面标识")
+    visited_routes = Column(JSON, nullable=True, comment="已访问路由列表")
+
+    # 新手引导与提示状态
+    has_shown_tap_to_chat = Column(Boolean, default=False, nullable=False, comment="是否已展示过 TapToChat 提示")
+    has_shown_cancel_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过取消按钮提示")
+    has_shown_send_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过发送按钮提示")
+    has_shown_progress_bar_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过进度条提示")
+
+    # Avatar-C 解锁状态
+    avatar_c_unlocked = Column(Boolean, default=False, nullable=False, comment="是否已解锁 Avatar-C")
+
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
+    updated_at = Column(
+        TIMESTAMP,
+        server_default=func.current_timestamp(),
+        onupdate=func.current_timestamp(),
+        comment="更新时间",
+    )
+
+    def to_dict(self) -> dict:
+        return {
+            "user_id": self.user_id,
+            "current_page": self.current_page,
+            "visited_routes": self.visited_routes or [],
+            "has_shown_tap_to_chat": self.has_shown_tap_to_chat,
+            "has_shown_cancel_tooltip": self.has_shown_cancel_tooltip,
+            "has_shown_send_tooltip": self.has_shown_send_tooltip,
+            "has_shown_progress_bar_tooltip": self.has_shown_progress_bar_tooltip,
+            "avatar_c_unlocked": self.avatar_c_unlocked,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+
 def _attach_metadata_alias(model_cls):
     """Allow using `metadata` keyword/attribute while storing in metadata_json."""
     original_init = model_cls.__init__

+ 97 - 3
backend/services/user_service.py

@@ -5,12 +5,12 @@ import uuid
 import random
 import asyncio
 import logging
-from typing import Dict, Optional
+from typing import Dict, Optional, Any
 from datetime import datetime
-from sqlalchemy import select, update, func
+from sqlalchemy import select, update, func, insert
 from sqlalchemy.ext.asyncio import AsyncSession
 from database import db_manager
-from models_db import User
+from models_db import User, UserUIState
 
 logger = logging.getLogger(__name__)
 
@@ -304,6 +304,100 @@ class UserService:
         except Exception as e:
             logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
             return None
+
+    async def get_user_ui_state(self, user_id: str) -> Optional[Dict[str, Any]]:
+        """
+        获取用户前端可视化状态
+        """
+        try:
+            async with db_manager.get_session() as session:
+                result = await session.execute(
+                    select(UserUIState).where(UserUIState.user_id == user_id)
+                )
+                state = result.scalar_one_or_none()
+
+            if state is None:
+                return None
+            return state.to_dict()
+        except Exception as e:  # noqa: BLE001
+            logger.error(f"❌ 获取用户UI状态失败: user_id={user_id}, error={e}")
+            return None
+
+    async def upsert_user_ui_state(
+        self,
+        user_id: str,
+        *,
+        current_page: Optional[str] = None,
+        visited_routes: Optional[list[str]] = None,
+        has_shown_tap_to_chat: Optional[bool] = None,
+        has_shown_cancel_tooltip: Optional[bool] = None,
+        has_shown_send_tooltip: Optional[bool] = None,
+        has_shown_progress_bar_tooltip: Optional[bool] = None,
+        avatar_c_unlocked: Optional[bool] = None,
+    ) -> Optional[Dict[str, Any]]:
+        """
+        创建或更新用户前端可视化状态(部分字段更新)
+        """
+        try:
+            async with db_manager.get_session() as session:
+                result = await session.execute(
+                    select(UserUIState).where(UserUIState.user_id == user_id).with_for_update()
+                )
+                state: Optional[UserUIState] = result.scalar_one_or_none()
+
+                if state is None:
+                    # 新建记录,仅写入非空字段
+                    data: Dict[str, Any] = {"user_id": user_id}
+                    if current_page is not None:
+                        data["current_page"] = current_page
+                    if visited_routes is not None:
+                        data["visited_routes"] = visited_routes
+                    if has_shown_tap_to_chat is not None:
+                        data["has_shown_tap_to_chat"] = has_shown_tap_to_chat
+                    if has_shown_cancel_tooltip is not None:
+                        data["has_shown_cancel_tooltip"] = has_shown_cancel_tooltip
+                    if has_shown_send_tooltip is not None:
+                        data["has_shown_send_tooltip"] = has_shown_send_tooltip
+                    if has_shown_progress_bar_tooltip is not None:
+                        data["has_shown_progress_bar_tooltip"] = has_shown_progress_bar_tooltip
+                    if avatar_c_unlocked is not None:
+                        data["avatar_c_unlocked"] = avatar_c_unlocked
+
+                    stmt = insert(UserUIState).values(**data)
+                    await session.execute(stmt)
+                else:
+                    # 更新现有记录(仅覆盖非空字段)
+                    if current_page is not None:
+                        state.current_page = current_page
+                    if visited_routes is not None:
+                        state.visited_routes = visited_routes
+                    if has_shown_tap_to_chat is not None:
+                        state.has_shown_tap_to_chat = has_shown_tap_to_chat
+                    if has_shown_cancel_tooltip is not None:
+                        state.has_shown_cancel_tooltip = has_shown_cancel_tooltip
+                    if has_shown_send_tooltip is not None:
+                        state.has_shown_send_tooltip = has_shown_send_tooltip
+                    if has_shown_progress_bar_tooltip is not None:
+                        state.has_shown_progress_bar_tooltip = has_shown_progress_bar_tooltip
+                    if avatar_c_unlocked is not None:
+                        state.avatar_c_unlocked = avatar_c_unlocked
+
+                    session.add(state)
+
+                await session.commit()
+
+                # 重新读取最新状态
+                result = await session.execute(
+                    select(UserUIState).where(UserUIState.user_id == user_id)
+                )
+                latest_state = result.scalar_one_or_none()
+
+            if latest_state is None:
+                return None
+            return latest_state.to_dict()
+        except Exception as e:  # noqa: BLE001
+            logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
+            return None
     
     async def register_with_phone(self, phone: str, username: str = "") -> Dict:
         """

BIN
frontend/src/assets/email-login.png


BIN
frontend/src/assets/gift-progress-bar.png


BIN
frontend/src/assets/phone-login.png


BIN
frontend/src/assets/qq-login.png


BIN
frontend/src/assets/wechat-login.png


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

@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useRef, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import { v4 as uuidv4 } from 'uuid';
 import { useAppStore } from '@/store';
@@ -6,10 +7,10 @@ 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 } from '@/utils/storage';
+import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
 import { performanceLogger } from '@/utils/logger';
-import { getUserInfo } from '@/utils/api';
+import { getUserInfo, updateUserUIState, getUserUIState } from '@/utils/api';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
@@ -40,6 +41,7 @@ const COUNTDOWN_THRESHOLD_MS = 10_000;
 const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
 
 export const ChatBotPage = () => {
+  const navigate = useNavigate();
   const {
     currentState,
     currentRequestId,
@@ -73,6 +75,17 @@ export const ChatBotPage = () => {
     if (!hasShown) {
       // 第一次打开,立即保存标记并显示
       setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
+
+      // 同步到后端 UI 状态(如果已有 user_id)
+      const userId = getUserId();
+      if (userId) {
+        updateUserUIState(userId, {
+          has_shown_tap_to_chat: true,
+        }).catch(() => {
+          // 同步失败不影响前端提示展示
+        });
+      }
+
       return true;
     }
     return false;
@@ -145,6 +158,15 @@ export const ChatBotPage = () => {
     avatarCUnlockedRef.current = true;
     setIsAvatarCUnlocked(true);
     setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
+
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        avatar_c_unlocked: true,
+      }).catch(() => {
+        // 同步失败不影响主流程
+      });
+    }
   }, []);
   const registerSpeechDuration = useCallback(
     (durationMs: number) => {
@@ -367,6 +389,14 @@ export const ChatBotPage = () => {
       if (!hasShownCancel) {
         setShowCancelTooltip(true);
         setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
+        const userId = getUserId();
+        if (userId) {
+          updateUserUIState(userId, {
+            has_shown_cancel_tooltip: true,
+          }).catch(() => {
+            // 同步失败不影响主流程
+          });
+        }
         
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 3秒后自动隐藏
@@ -392,6 +422,14 @@ export const ChatBotPage = () => {
       if (!hasShownSend) {
         setShowSendTooltip(true);
         setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
+        const userId = getUserId();
+        if (userId) {
+          updateUserUIState(userId, {
+            has_shown_send_tooltip: true,
+          }).catch(() => {
+            // 同步失败不影响主流程
+          });
+        }
         
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 3秒后自动隐藏
@@ -442,6 +480,14 @@ export const ChatBotPage = () => {
       setShowProgressBarTooltip(true);
       setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
       setHasShownProgressBarTooltip(true);
+      const userId = getUserId();
+      if (userId) {
+        updateUserUIState(userId, {
+          has_shown_progress_bar_tooltip: true,
+        }).catch(() => {
+          // 同步失败不影响主流程
+        });
+      }
     }
   }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
 
@@ -498,6 +544,58 @@ export const ChatBotPage = () => {
     
     // 记录路由访问
     markRouteVisited('/chatbot');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      // 先尝试从后端拉取 UI 状态用于恢复本地提示与 Avatar 状态
+      getUserUIState(userId)
+        .then((resp) => {
+          if (!resp.success || !resp.data) {
+            return;
+          }
+          const state = resp.data;
+
+          if (state.current_page) {
+            setStorage(STORAGE_KEYS.CURRENT_PAGE, state.current_page);
+          }
+          if (Array.isArray(state.visited_routes)) {
+            setStorage(STORAGE_KEYS.VISITED_ROUTES, JSON.stringify(state.visited_routes));
+          }
+          if (state.has_shown_tap_to_chat) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
+            setShowTapToChat(false);
+          }
+          if (state.has_shown_cancel_tooltip) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
+            setShowCancelTooltip(false);
+          }
+          if (state.has_shown_send_tooltip) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
+            setShowSendTooltip(false);
+          }
+          if (state.has_shown_progress_bar_tooltip) {
+            setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
+            setHasShownProgressBarTooltip(true);
+          }
+          if (state.avatar_c_unlocked && !avatarCUnlockedRef.current) {
+            avatarCUnlockedRef.current = true;
+            setIsAvatarCUnlocked(true);
+            setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
+          }
+        })
+        .catch(() => {
+          // 拉取失败不影响主流程
+        });
+
+      // 同步当前页面与已访问路由
+      updateUserUIState(userId, {
+        current_page: 'chatbot',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响主流程
+      });
+    }
     
     const init = async () => {
       try {
@@ -1049,6 +1147,7 @@ export const ChatBotPage = () => {
       >
         <div
           className="text-fluid-sm"
+          onClick={() => navigate('/level5-welcome')}
           style={{
             fontWeight: 500,
             color: 'rgba(255, 255, 255, 0.9)',
@@ -1058,6 +1157,18 @@ export const ChatBotPage = () => {
             background: 'rgba(255, 255, 255, 0.1)',
             backdropFilter: 'blur(10px)',
             border: '1px solid rgba(255, 255, 255, 0.2)',
+            cursor: 'pointer',
+            userSelect: 'none',
+            WebkitUserSelect: 'none',
+            transition: 'all 0.2s ease',
+          }}
+          onMouseEnter={(e) => {
+            e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
+            e.currentTarget.style.transform = 'scale(1.05)';
+          }}
+          onMouseLeave={(e) => {
+            e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
+            e.currentTarget.style.transform = 'scale(1)';
           }}
         >
           {currentEmotion}

+ 13 - 2
frontend/src/pages/GreetingPage.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '../utils/storage';
-import { getUserInfo, updateUsername } from '../utils/api';
+import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '../utils/storage';
+import { getUserInfo, updateUsername, updateUserUIState } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -26,6 +26,17 @@ export const GreetingPage = () => {
       
       // 记录路由访问
       markRouteVisited('/naming');
+
+      const visited = getVisitedRoutes();
+      const userId = getUserId();
+      if (userId) {
+        updateUserUIState(userId, {
+          current_page: 'naming',
+          visited_routes: visited,
+        }).catch(() => {
+          // 同步失败不影响前端流程
+        });
+      }
       
       // 检查 localStorage 中是否存在 Lyrria_user_name
       const savedName = getStorage(STORAGE_KEYS.USER_NAME);

+ 365 - 0
frontend/src/pages/Level5WelcomePage.tsx

@@ -0,0 +1,365 @@
+import { useEffect } from 'react';
+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';
+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 Level5WelcomePage = () => {
+
+  useEffect(() => {
+    // 记录用户当前所在页面
+    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'level5-welcome');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'level5-welcome',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+  }, []);
+
+  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 1.5s 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 1.5s 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 1.5s 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 1.5s forwards',
+        }}
+      >
+        恭喜你成功升到5级!聊了这么长时间,不如登陆一下让我们保持联系吧。登陆后有惊喜礼物等着你哦
+      </div>
+      {/* 微信登录按钮 - Y: 567, 左右居中, width: 279,文字消失后显示 */}
+      <img
+        src={wechatLogin}
+        alt="wechat login"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(567),
+          width: px2vmin(279),
+          transform: 'translateX(-50%)',
+          opacity: 0,
+          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
+        }}
+      />
+      {/* 其他登录方式文字 - Y: 687, 左右居中, 12px, 颜色F5F3F0,文字消失后显示 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(687),
+          transform: 'translateX(-50%)',
+          color: '#F5F3F0',
+          fontSize: px2vmin(12),
+          whiteSpace: 'nowrap',
+          opacity: 0,
+          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
+        }}
+      >
+        —— 其他登陆方式 ——
+      </div>
+      {/* 按钮行 - Y: 715, 左右居中,三个按钮,间距32px */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(715),
+          transform: 'translateX(-50%)',
+          display: 'flex',
+          gap: px2vmin(32),
+          alignItems: 'center',
+          justifyContent: 'center',
+          width: 'fit-content',
+          opacity: 0,
+          animation: 'fadeIn 0.3s ease-in 7.9s forwards',
+        }}
+      >
+        <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>
+      {/* 扩展圆形动画 - 设计稿:圆心(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;
+          }
+          100% {
+            width: ${px2vmin(236 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(236 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand2 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(332.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(332.5 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand3 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(340 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            height: ${px2vmin(340 * 2)};
+            opacity: 0;
+          }
+        }
+        @keyframes circleExpand4 {
+          0% {
+            width: 0;
+            height: 0;
+            opacity: 1;
+          }
+          100% {
+            width: ${px2vmin(258.5 * 2)}; /* 直径 = 半径 * 2,使用 vmin 保持圆形 */
+            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>
+  );
+};
+

+ 13 - 2
frontend/src/pages/LumiNamingPage.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId } from '../utils/storage';
-import { updateBotName } from '../utils/api';
+import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } from '../utils/storage';
+import { updateBotName, updateUserUIState } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -27,6 +27,17 @@ export const LumiNamingPage = () => {
     
     // 记录路由访问
     markRouteVisited('/lumi-naming');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'lumi-naming',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
   }, []);
 
   // 监听 Lumi 姓名变化,同步更新 localStorage

+ 9 - 1
frontend/src/pages/RootRedirect.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { getNextUnvisitedRoute, hasUserId, setUserId, setUserName } from '@/utils/storage';
 import { createUser } from '@/utils/api';
@@ -11,8 +11,16 @@ import { handleApiError } from '@/utils/errorHandler';
  */
 export const RootRedirect = () => {
   const navigate = useNavigate();
+  const hasRunRef = useRef(false); // 防止在 React 严格模式下重复执行
 
   useEffect(() => {
+    // 在开发环境的 React.StrictMode 下,useEffect 会执行两次
+    // 这里用 ref 保证整个重定向逻辑只跑一次,避免重复创建用户请求
+    if (hasRunRef.current) {
+      return;
+    }
+    hasRunRef.current = true;
+
     const handleRedirect = async () => {
       try {
         console.log('[RootRedirect] 开始处理重定向...');

+ 16 - 1
frontend/src/pages/VideoIntroPage.tsx

@@ -1,7 +1,8 @@
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, getVisitedRoutes, markRouteVisited, getUserId } from '@/utils/storage';
+import { updateUserUIState } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import './VideoIntroPage.css';
 import chatbotBg from '@/assets/chatbot-bg.svg';
@@ -19,6 +20,20 @@ export const VideoIntroPage = () => {
     // 记录用户当前所在页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, 'video');
 
+    // 记录路由访问(视频引导页本身不计入流程,但用于兼容历史数据)
+    markRouteVisited('/welcome');
+
+    const userId = getUserId();
+    if (userId) {
+      const visited = getVisitedRoutes();
+      updateUserUIState(userId, {
+        current_page: 'video',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+
     // 3秒加载倒计时
     const loadingTimer = setTimeout(() => {
       setIsLoading(false);

+ 14 - 1
frontend/src/pages/WelcomePage.tsx

@@ -1,7 +1,8 @@
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS, markRouteVisited } from '@/utils/storage';
+import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId } from '@/utils/storage';
+import { updateUserUIState } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import lumiImage from '@/assets/Lumi-image.png';
 import './WelcomePage.css';
@@ -34,6 +35,18 @@ export const WelcomePage = () => {
     // 记录路由访问
     markRouteVisited('/welcome');
 
+    // 将页面与路由状态同步到后端(如果已有 user_id)
+    const userId = getUserId();
+    if (userId) {
+      const visited = getVisitedRoutes();
+      updateUserUIState(userId, {
+        current_page: 'welcome',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程,静默处理
+      });
+    }
+
     // 🚀 提前预加载视频资源(使用 link preload)
     const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || 'https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4';
     const preloadLink = document.createElement('link');

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

@@ -8,6 +8,7 @@ import { ChatBotPage } from '@/pages/ChatBotPage';
 import { ErrorPage } from '@/pages/ErrorPage';
 import { RegisterPage } from '@/pages/RegisterPage';
 import { SharePage } from '@/pages/SharePage';
+import { Level5WelcomePage } from '@/pages/Level5WelcomePage';
 import { UserGuard } from '@/components/UserGuard';
 
 const basename = import.meta.env.BASE_URL || '/';
@@ -45,6 +46,10 @@ export const router = createBrowserRouter([
         path: '/chatbot',
         element: <ChatBotPage />,
       },
+      {
+        path: '/level5-welcome',
+        element: <Level5WelcomePage />,
+      },
       {
         path: '/error',
         element: <ErrorPage />,

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

@@ -175,6 +175,72 @@ export const updateBotName = async (user_id: string, bot_name: string): Promise<
   return response.json();
 };
 
+/**
+ * 用户 UI 状态相关接口
+ */
+
+export interface UserUIState {
+  current_page: string | null;
+  visited_routes: string[];
+  has_shown_tap_to_chat: boolean;
+  has_shown_cancel_tooltip: boolean;
+  has_shown_send_tooltip: boolean;
+  has_shown_progress_bar_tooltip: boolean;
+  avatar_c_unlocked: boolean;
+}
+
+export interface GetUserUIStateResponse {
+  success: boolean;
+  data: {
+    user_id: string;
+  } & UserUIState;
+}
+
+export const getUserUIState = async (user_id: string): Promise<GetUserUIStateResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/ui-state`, {
+    method: 'GET',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    throw new Error(`获取用户UI状态失败: ${response.statusText}`);
+  }
+
+  return response.json();
+};
+
+export interface UpdateUserUIStateResponse {
+  success: boolean;
+  data: {
+    user_id: string;
+  } & UserUIState;
+}
+
+export type UpdateUserUIStatePayload = Partial<UserUIState>;
+
+export const updateUserUIState = async (
+  user_id: string,
+  payload: UpdateUserUIStatePayload,
+): Promise<UpdateUserUIStateResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/ui-state`, {
+    method: 'PUT',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify(payload),
+  });
+
+  if (!response.ok) {
+    throw new Error(`更新用户UI状态失败: ${response.statusText}`);
+  }
+
+  return response.json();
+};
+
 /**
  * 注册/登录相关接口
  */