zlwu 11 months ago
parent
commit
cbedba982f

+ 74 - 1
backend/main.py

@@ -5,7 +5,7 @@ import json
 import logging
 import logging
 import urllib.parse
 import urllib.parse
 import hashlib
 import hashlib
-from typing import Optional
+from typing import Optional, List, Dict, Any
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query
 from fastapi.responses import RedirectResponse, PlainTextResponse
 from fastapi.responses import RedirectResponse, PlainTextResponse
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
@@ -140,6 +140,31 @@ class LoginEmailRequest(BaseModel):
     email: str
     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")
 @app.post("/api/users/create")
 async def create_user(request: CreateUserRequest):
 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)}")
         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 授权端点 ====================
 # ==================== OAuth 授权端点 ====================
 
 
 @app.get("/api/auth/wechat/authorize")
 @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 datetime import datetime
 from typing import Optional
 from typing import Optional
 from sqlalchemy import (
 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.orm import declarative_base
 from sqlalchemy.sql import func
 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):
 def _attach_metadata_alias(model_cls):
     """Allow using `metadata` keyword/attribute while storing in metadata_json."""
     """Allow using `metadata` keyword/attribute while storing in metadata_json."""
     original_init = model_cls.__init__
     original_init = model_cls.__init__

+ 97 - 3
backend/services/user_service.py

@@ -5,12 +5,12 @@ import uuid
 import random
 import random
 import asyncio
 import asyncio
 import logging
 import logging
-from typing import Dict, Optional
+from typing import Dict, Optional, Any
 from datetime import datetime
 from datetime import datetime
-from sqlalchemy import select, update, func
+from sqlalchemy import select, update, func, insert
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from database import db_manager
 from database import db_manager
-from models_db import User
+from models_db import User, UserUIState
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -304,6 +304,100 @@ class UserService:
         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
+
+    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:
     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 { useCallback, useEffect, useRef, useState } from 'react';
+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';
 import { useAppStore } from '@/store';
 import { useAppStore } from '@/store';
@@ -6,10 +7,10 @@ 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 } from '@/utils/storage';
+import { setStorage, getStorage, STORAGE_KEYS, markRouteVisited, getUserId, getVisitedRoutes } 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 } from '@/utils/api';
+import { getUserInfo, updateUserUIState, getUserUIState } from '@/utils/api';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 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';
 const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
 
 
 export const ChatBotPage = () => {
 export const ChatBotPage = () => {
+  const navigate = useNavigate();
   const {
   const {
     currentState,
     currentState,
     currentRequestId,
     currentRequestId,
@@ -73,6 +75,17 @@ export const ChatBotPage = () => {
     if (!hasShown) {
     if (!hasShown) {
       // 第一次打开,立即保存标记并显示
       // 第一次打开,立即保存标记并显示
       setStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT, 'true');
       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 true;
     }
     }
     return false;
     return false;
@@ -145,6 +158,15 @@ export const ChatBotPage = () => {
     avatarCUnlockedRef.current = true;
     avatarCUnlockedRef.current = true;
     setIsAvatarCUnlocked(true);
     setIsAvatarCUnlocked(true);
     setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
     setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
+
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        avatar_c_unlocked: true,
+      }).catch(() => {
+        // 同步失败不影响主流程
+      });
+    }
   }, []);
   }, []);
   const registerSpeechDuration = useCallback(
   const registerSpeechDuration = useCallback(
     (durationMs: number) => {
     (durationMs: number) => {
@@ -367,6 +389,14 @@ export const ChatBotPage = () => {
       if (!hasShownCancel) {
       if (!hasShownCancel) {
         setShowCancelTooltip(true);
         setShowCancelTooltip(true);
         setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
         setStorage(STORAGE_KEYS.HAS_SHOWN_CANCEL_TOOLTIP, 'true');
+        const userId = getUserId();
+        if (userId) {
+          updateUserUIState(userId, {
+            has_shown_cancel_tooltip: true,
+          }).catch(() => {
+            // 同步失败不影响主流程
+          });
+        }
         
         
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 3秒后自动隐藏
         // 3秒后自动隐藏
@@ -392,6 +422,14 @@ export const ChatBotPage = () => {
       if (!hasShownSend) {
       if (!hasShownSend) {
         setShowSendTooltip(true);
         setShowSendTooltip(true);
         setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
         setStorage(STORAGE_KEYS.HAS_SHOWN_SEND_TOOLTIP, 'true');
+        const userId = getUserId();
+        if (userId) {
+          updateUserUIState(userId, {
+            has_shown_send_tooltip: true,
+          }).catch(() => {
+            // 同步失败不影响主流程
+          });
+        }
         
         
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 🔧 调试模式:注释掉自动隐藏,方便调试
         // 3秒后自动隐藏
         // 3秒后自动隐藏
@@ -442,6 +480,14 @@ export const ChatBotPage = () => {
       setShowProgressBarTooltip(true);
       setShowProgressBarTooltip(true);
       setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
       setStorage(STORAGE_KEYS.HAS_SHOWN_PROGRESS_BAR_TOOLTIP, 'true');
       setHasShownProgressBarTooltip(true);
       setHasShownProgressBarTooltip(true);
+      const userId = getUserId();
+      if (userId) {
+        updateUserUIState(userId, {
+          has_shown_progress_bar_tooltip: true,
+        }).catch(() => {
+          // 同步失败不影响主流程
+        });
+      }
     }
     }
   }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
   }, [displayProgress, hasShownProgressBarTooltip, isConnected, currentState]);
 
 
@@ -498,6 +544,58 @@ export const ChatBotPage = () => {
     
     
     // 记录路由访问
     // 记录路由访问
     markRouteVisited('/chatbot');
     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 () => {
     const init = async () => {
       try {
       try {
@@ -1049,6 +1147,7 @@ export const ChatBotPage = () => {
       >
       >
         <div
         <div
           className="text-fluid-sm"
           className="text-fluid-sm"
+          onClick={() => navigate('/level5-welcome')}
           style={{
           style={{
             fontWeight: 500,
             fontWeight: 500,
             color: 'rgba(255, 255, 255, 0.9)',
             color: 'rgba(255, 255, 255, 0.9)',
@@ -1058,6 +1157,18 @@ export const ChatBotPage = () => {
             background: 'rgba(255, 255, 255, 0.1)',
             background: 'rgba(255, 255, 255, 0.1)',
             backdropFilter: 'blur(10px)',
             backdropFilter: 'blur(10px)',
             border: '1px solid rgba(255, 255, 255, 0.2)',
             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}
           {currentEmotion}

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

@@ -1,7 +1,7 @@
 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 { 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 { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -26,6 +26,17 @@ export const GreetingPage = () => {
       
       
       // 记录路由访问
       // 记录路由访问
       markRouteVisited('/naming');
       markRouteVisited('/naming');
+
+      const visited = getVisitedRoutes();
+      const userId = getUserId();
+      if (userId) {
+        updateUserUIState(userId, {
+          current_page: 'naming',
+          visited_routes: visited,
+        }).catch(() => {
+          // 同步失败不影响前端流程
+        });
+      }
       
       
       // 检查 localStorage 中是否存在 Lyrria_user_name
       // 检查 localStorage 中是否存在 Lyrria_user_name
       const savedName = getStorage(STORAGE_KEYS.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 { useState, useEffect, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 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 { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import AvatarZone from '../assets/Avatar-anima-zone.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
 import ConfirmBtnImage from '../assets/confirm-btn.png';
@@ -27,6 +27,17 @@ export const LumiNamingPage = () => {
     
     
     // 记录路由访问
     // 记录路由访问
     markRouteVisited('/lumi-naming');
     markRouteVisited('/lumi-naming');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'lumi-naming',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
   }, []);
   }, []);
 
 
   // 监听 Lumi 姓名变化,同步更新 localStorage
   // 监听 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 { useNavigate } from 'react-router-dom';
 import { getNextUnvisitedRoute, hasUserId, setUserId, setUserName } from '@/utils/storage';
 import { getNextUnvisitedRoute, hasUserId, setUserId, setUserName } from '@/utils/storage';
 import { createUser } from '@/utils/api';
 import { createUser } from '@/utils/api';
@@ -11,8 +11,16 @@ import { handleApiError } from '@/utils/errorHandler';
  */
  */
 export const RootRedirect = () => {
 export const RootRedirect = () => {
   const navigate = useNavigate();
   const navigate = useNavigate();
+  const hasRunRef = useRef(false); // 防止在 React 严格模式下重复执行
 
 
   useEffect(() => {
   useEffect(() => {
+    // 在开发环境的 React.StrictMode 下,useEffect 会执行两次
+    // 这里用 ref 保证整个重定向逻辑只跑一次,避免重复创建用户请求
+    if (hasRunRef.current) {
+      return;
+    }
+    hasRunRef.current = true;
+
     const handleRedirect = async () => {
     const handleRedirect = async () => {
       try {
       try {
         console.log('[RootRedirect] 开始处理重定向...');
         console.log('[RootRedirect] 开始处理重定向...');

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

@@ -1,7 +1,8 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import videojs from 'video.js';
 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 'video.js/dist/video-js.css';
 import './VideoIntroPage.css';
 import './VideoIntroPage.css';
 import chatbotBg from '@/assets/chatbot-bg.svg';
 import chatbotBg from '@/assets/chatbot-bg.svg';
@@ -19,6 +20,20 @@ export const VideoIntroPage = () => {
     // 记录用户当前所在页面
     // 记录用户当前所在页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, 'video');
     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秒加载倒计时
     // 3秒加载倒计时
     const loadingTimer = setTimeout(() => {
     const loadingTimer = setTimeout(() => {
       setIsLoading(false);
       setIsLoading(false);

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

@@ -1,7 +1,8 @@
 import { useEffect, useRef, useState } from 'react';
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import videojs from 'video.js';
 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 'video.js/dist/video-js.css';
 import lumiImage from '@/assets/Lumi-image.png';
 import lumiImage from '@/assets/Lumi-image.png';
 import './WelcomePage.css';
 import './WelcomePage.css';
@@ -34,6 +35,18 @@ export const WelcomePage = () => {
     // 记录路由访问
     // 记录路由访问
     markRouteVisited('/welcome');
     markRouteVisited('/welcome');
 
 
+    // 将页面与路由状态同步到后端(如果已有 user_id)
+    const userId = getUserId();
+    if (userId) {
+      const visited = getVisitedRoutes();
+      updateUserUIState(userId, {
+        current_page: 'welcome',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程,静默处理
+      });
+    }
+
     // 🚀 提前预加载视频资源(使用 link preload)
     // 🚀 提前预加载视频资源(使用 link preload)
     const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || 'https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4';
     const videoUrl = import.meta.env.VITE_WELCOME_VIDEO_URL || 'https://tolan.oss-cn-hangzhou.aliyuncs.com/ABC.mp4';
     const preloadLink = document.createElement('link');
     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 { ErrorPage } from '@/pages/ErrorPage';
 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 { UserGuard } from '@/components/UserGuard';
 import { UserGuard } from '@/components/UserGuard';
 
 
 const basename = import.meta.env.BASE_URL || '/';
 const basename = import.meta.env.BASE_URL || '/';
@@ -45,6 +46,10 @@ export const router = createBrowserRouter([
         path: '/chatbot',
         path: '/chatbot',
         element: <ChatBotPage />,
         element: <ChatBotPage />,
       },
       },
+      {
+        path: '/level5-welcome',
+        element: <Level5WelcomePage />,
+      },
       {
       {
         path: '/error',
         path: '/error',
         element: <ErrorPage />,
         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();
   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();
+};
+
 /**
 /**
  * 注册/登录相关接口
  * 注册/登录相关接口
  */
  */