zlwu 11 miesięcy temu
rodzic
commit
65c0ba562a

+ 2 - 0
backend/main.py

@@ -153,6 +153,7 @@ class UserUIStateResponse(BaseModel):
     has_shown_mission_stone_modal: bool = False
     has_shown_conversation_stone_modal: bool = False
     avatar_c_unlocked: bool = False
+    has_shared_avatar: bool = False
 
 
 class UpdateUserUIStateRequest(BaseModel):
@@ -167,6 +168,7 @@ class UpdateUserUIStateRequest(BaseModel):
     has_shown_mission_stone_modal: Optional[bool] = None
     has_shown_conversation_stone_modal: Optional[bool] = None
     avatar_c_unlocked: Optional[bool] = None
+    has_shared_avatar: Optional[bool] = None
 
 
 @app.post("/api/users/create")

+ 74 - 0
backend/migrate_add_has_shared_avatar.py

@@ -0,0 +1,74 @@
+"""
+数据库迁移脚本:为 user_ui_states 表添加 has_shared_avatar 字段
+运行: python migrate_add_has_shared_avatar.py
+"""
+import asyncio
+import sys
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_add_has_shared_avatar() -> bool:
+  """为 user_ui_states 表添加 has_shared_avatar 字段"""
+  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 = 'user_ui_states'
+          AND COLUMN_NAME = 'has_shared_avatar'
+        """,
+        (settings.MYSQL_DATABASE,),
+      )
+      result = await cursor.fetchone()
+      if result and result[0] > 0:
+        conn.close()
+        return True
+
+      # 添加字段,默认 false
+      await cursor.execute(
+        """
+        ALTER TABLE user_ui_states
+        ADD COLUMN has_shared_avatar TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已分享头像奖励'
+        AFTER avatar_c_unlocked
+        """
+      )
+      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)
+
+  ok = asyncio.run(migrate_add_has_shared_avatar())
+  if not ok:
+    sys.exit(1)
+
+

+ 2 - 0
backend/models_db.py

@@ -270,6 +270,7 @@ class UserUIState(Base):
 
     # Avatar-C 解锁状态
     avatar_c_unlocked = Column(Boolean, default=False, nullable=False, comment="是否已解锁 Avatar-C")
+    has_shared_avatar = Column(Boolean, default=False, nullable=False, comment="是否已分享头像奖励")
 
     created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
     updated_at = Column(
@@ -291,6 +292,7 @@ class UserUIState(Base):
             "has_shown_mission_stone_modal": self.has_shown_mission_stone_modal,
             "has_shown_conversation_stone_modal": self.has_shown_conversation_stone_modal,
             "avatar_c_unlocked": self.avatar_c_unlocked,
+            "has_shared_avatar": self.has_shared_avatar,
             "created_at": self.created_at.isoformat() if self.created_at else None,
             "updated_at": self.updated_at.isoformat() if self.updated_at else None,
         }

+ 5 - 0
backend/services/user_service.py

@@ -371,6 +371,7 @@ class UserService:
         has_shown_mission_stone_modal: Optional[bool] = None,
         has_shown_conversation_stone_modal: Optional[bool] = None,
         avatar_c_unlocked: Optional[bool] = None,
+        has_shared_avatar: Optional[bool] = None,
     ) -> Optional[Dict[str, Any]]:
         """
         创建或更新用户前端可视化状态(部分字段更新)
@@ -403,6 +404,8 @@ class UserService:
                         data["has_shown_conversation_stone_modal"] = has_shown_conversation_stone_modal
                     if avatar_c_unlocked is not None:
                         data["avatar_c_unlocked"] = avatar_c_unlocked
+                    if has_shared_avatar is not None:
+                        data["has_shared_avatar"] = has_shared_avatar
 
                     stmt = insert(UserUIState).values(**data)
                     await session.execute(stmt)
@@ -426,6 +429,8 @@ class UserService:
                         state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
                     if avatar_c_unlocked is not None:
                         state.avatar_c_unlocked = avatar_c_unlocked
+                    if has_shared_avatar is not None:
+                        state.has_shared_avatar = has_shared_avatar
 
                     session.add(state)
 

+ 0 - 0
frontend/src/assets/Avatar-anima-zone.png → frontend/src/assets/Avatar-anima-zone-1.png


+ 0 - 0
frontend/src/assets/Avatar-anima-zone.svg → frontend/src/assets/Avatar-anima-zone1.svg


+ 0 - 0
frontend/src/assets/avatar-anima-zone copy.png → frontend/src/assets/avatar-anima-zone-copy.png


BIN
frontend/src/assets/avatar-gift.png


+ 10 - 3
frontend/src/pages/ChatBotPage.tsx

@@ -11,6 +11,7 @@ import {
   setStorage,
   getStorage,
   getUserLevel,
+  getHasSharedAvatar,
   STORAGE_KEYS,
   markRouteVisited,
   getUserId,
@@ -24,6 +25,7 @@ import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import type { Message, EnergyStoneSpawnMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import avatarC from '@/assets/Avatar-C.svg';
+import avatarGift from '@/assets/avatar-gift.png';
 import treesImage from '@/assets/Group-3465183.svg';
 import audioButton from '@/assets/audio-button.png';
 import avatarHint from '@/assets/Avatar-hint.png';
@@ -104,6 +106,7 @@ export const ChatBotPage = () => {
   const audioWaveRef = useRef<AudioWaveHandle | null>(null);
   const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
   const levelRedirectRef = useRef(false);
+  const [hasSharedAvatar, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
   // 初始化时检查 localStorage,确保第一次打开时立即显示提示
   const [showTapToChat, setShowTapToChat] = useState(() => {
     const hasShown = getStorage(STORAGE_KEYS.HAS_SHOWN_TAP_TO_CHAT);
@@ -635,6 +638,10 @@ export const ChatBotPage = () => {
             setIsAvatarCUnlocked(true);
             setStorage(STORAGE_KEYS.AVATAR_C_UNLOCKED, 'true');
           }
+          if (state.has_shared_avatar) {
+            setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, 'true');
+            setHasSharedAvatarState(true);
+          }
           if (state.has_shown_mission_stone_modal) {
             setStorage(STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL, 'true');
           }
@@ -1313,7 +1320,7 @@ export const ChatBotPage = () => {
           height: px2vmin(40),
           top: px2vmin(63),
           right: px2vmin(33),
-          zIndex: isAvatarCUnlocked ? 30 : 5,
+          zIndex: hasSharedAvatar ? 5 : 30,
           pointerEvents: isAvatarCUnlocked ? 'auto' : 'none',
           cursor: isAvatarCUnlocked ? 'pointer' : 'default',
         }}
@@ -1330,7 +1337,7 @@ export const ChatBotPage = () => {
           />
         )}
         <img
-          src={avatarC}
+          src={hasSharedAvatar ? avatarC : avatarGift}
           alt="Avatar C"
           className="rounded-full"
           style={{
@@ -1339,7 +1346,7 @@ export const ChatBotPage = () => {
             opacity: isAvatarCUnlocked ? 1 : 0.2,
             transition: 'opacity 0.4s ease',
             position: 'relative',
-            zIndex: 1,
+            zIndex: hasSharedAvatar ? 1 : 30,
           }}
         />
         {userInitial && (

+ 19 - 4
frontend/src/pages/FirstLoginPage.tsx

@@ -1,4 +1,5 @@
 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 { px2vmin } from '@/utils/viewport';
@@ -9,6 +10,8 @@ import phoneLogin from '@/assets/phone-login.png';
 import emailLogin from '@/assets/email-login.png';
 
 export const FirstLoginPage = () => {
+  const navigate = useNavigate();
+
   useEffect(() => {
     // 记录用户当前所在页面
     setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login');
@@ -52,17 +55,29 @@ export const FirstLoginPage = () => {
         }}
       />
       {/* 微信登录按钮 */}
-      <img
-        src={wechatLogin}
-        alt="wechat login"
+      <button
+        type="button"
+        onClick={() => navigate('/first-login-reward')}
         style={{
           position: 'absolute',
           left: '50%',
           top: px2vmin(567),
           width: px2vmin(279),
           transform: 'translateX(-50%)',
+          border: 'none',
+          padding: 0,
+          background: 'none',
         }}
-      />
+      >
+        <img
+          src={wechatLogin}
+          alt="wechat login"
+          style={{
+            width: '100%',
+            display: 'block',
+          }}
+        />
+      </button>
       {/* 其他登录方式文字 */}
       <div
         style={{

+ 153 - 0
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -0,0 +1,153 @@
+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 { px2vmin } from '@/utils/viewport';
+import backButton from '@/assets/backbutn.png';
+import shareProgressBar from '@/assets/share-progress-bar.png';
+import avatarAnimaZone from '@/assets/avatar-anima-zone-copy.png';
+import shareBtn from '@/assets/share-btn.png';
+
+export const FirstLoginRewardPage = () => {
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    // 记录用户当前所在页面
+    setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login-reward');
+
+    const visited = getVisitedRoutes();
+    const userId = getUserId();
+    if (userId) {
+      updateUserUIState(userId, {
+        current_page: 'first-login-reward',
+        visited_routes: visited,
+      }).catch(() => {
+        // 同步失败不影响前端流程
+      });
+    }
+  }, []);
+
+  const handleBack = () => {
+    navigate('/chatbot');
+  };
+
+  const handleShareClick = () => {
+    // 分享逻辑后续接入,这里先占位
+  };
+
+  return (
+    <div>
+      {/* 顶部返回按钮 */}
+      <button
+        type="button"
+        onClick={handleBack}
+        style={{
+          position: 'absolute',
+          left: px2vmin(16),
+          top: px2vmin(56),
+          width: px2vmin(48),
+          height: px2vmin(48),
+          padding: 0,
+          border: 'none',
+          background: 'none',
+        }}
+        aria-label="Back"
+      >
+        <img
+          src={backButton}
+          alt="Back"
+          style={{
+            width: '100%',
+            height: '100%',
+            display: 'block',
+          }}
+        />
+      </button>
+
+      {/* 顶部进度条 */}
+      <img
+        src={shareProgressBar}
+        alt="share progress bar"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(61),
+          width: px2vmin(195),
+          transform: 'translateX(-50%)',
+        }}
+      />
+
+      {/* 中部头像动画区域 */}
+      <img
+        src={avatarAnimaZone}
+        alt="avatar anima zone"
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(192),
+          width: px2vmin(343),
+          transform: 'translateX(-50%)',
+        }}
+      />
+
+      {/* 标题文案 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(504),
+          transform: 'translateX(-50%)',
+          color: '#FFFFFF',
+          fontSize: px2vmin(24),
+          fontWeight: 'bold',
+          whiteSpace: 'nowrap',
+        }}
+      >
+        Congratulation!
+      </div>
+
+      {/* 描述文案 */}
+      <div
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(552),
+          transform: 'translateX(-50%)',
+          color: '#FFFFFF',
+          fontSize: px2vmin(14),
+          textAlign: 'center',
+          // width: px2vmin(270),
+        }}
+      >
+        哇,是一顶小帽子,谢谢你!要到新年了,快来分享我的新装扮并解锁限定新年背景吧
+      </div>
+
+      {/* 分享按钮 */}
+      <button
+        type="button"
+        onClick={handleShareClick}
+        style={{
+          position: 'absolute',
+          left: '50%',
+          top: px2vmin(656),
+          transform: 'translateX(-50%)',
+          border: 'none',
+          padding: 0,
+          background: 'none',
+          height: px2vmin(56),
+        }}
+      >
+        <img
+          src={shareBtn}
+          alt="Share"
+          style={{
+            height: '100%',
+            display: 'block',
+          }}
+        />
+      </button>
+    </div>
+  );
+};
+
+

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

@@ -38,8 +38,9 @@ export const WelcomePage = () => {
     if (!getStorage(STORAGE_KEYS.USER_LEVEL)) {
       setStorage(STORAGE_KEYS.USER_LEVEL, '0');
     }
-    // 默认标记为未登录
+    // 默认标记为未登录、未分享
     setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'false');
+    setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, 'false');
     
     // 记录路由访问
     markRouteVisited('/welcome');

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

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

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

@@ -10,6 +10,7 @@ import { SharePage } from '@/pages/SharePage';
 import { Level5WelcomePage } from '@/pages/Level5WelcomePage';
 import { Level34WelcomePage } from '@/pages/Level34WelcomePage';
 import { FirstLoginPage } from '@/pages/FirstLoginPage';
+import { FirstLoginRewardPage } from '@/pages/FirstLoginRewardPage';
 import { LoginPage } from '@/pages/LoginPage';
 import { UserGuard } from '@/components/UserGuard';
 
@@ -60,6 +61,10 @@ export const router = createBrowserRouter([
         path: '/first-login',
         element: <FirstLoginPage />,
       },
+      {
+        path: '/first-login-reward',
+        element: <FirstLoginRewardPage />,
+      },
       {
         path: '/login',
         element: <LoginPage />,

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

@@ -191,6 +191,7 @@ export interface UserUIState {
   has_shown_mission_stone_modal: boolean;
   has_shown_conversation_stone_modal: boolean;
   avatar_c_unlocked: boolean;
+  has_shared_avatar: boolean;
 }
 
 export interface GetUserUIStateResponse {

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

@@ -19,6 +19,7 @@ export const STORAGE_KEYS = {
   AVATAR_C_UNLOCKED: 'Lyrria_avatar_c_unlocked', // 是否已永久展示 Avatar-C
   IS_LOGGED_IN: 'Lyrria_is_logged_in', // 是否已登录
   USER_LEVEL: 'Lyrria_user_level', // 用户当前等级
+  HAS_SHARED_AVATAR: 'Lyrria_has_shared_avatar', // 是否已分享头像奖励
 } as const;
 
 // 路由顺序表
@@ -234,6 +235,20 @@ export const setUserLevel = (level: number): boolean => {
   return setStorage(STORAGE_KEYS.USER_LEVEL, String(level));
 };
 
+/**
+ * 获取是否已分享头像奖励
+ */
+export const getHasSharedAvatar = (): boolean => {
+  return getStorage(STORAGE_KEYS.HAS_SHARED_AVATAR) === 'true';
+};
+
+/**
+ * 设置是否已分享头像奖励
+ */
+export const setHasSharedAvatar = (value: boolean): boolean => {
+  return setStorage(STORAGE_KEYS.HAS_SHARED_AVATAR, value ? 'true' : 'false');
+};
+
 /**
  * 清除用户相关的路由和页面状态
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page