zlwu 11 hónapja
szülő
commit
a1bda48db6

+ 96 - 0
backend/main.py

@@ -396,6 +396,102 @@ async def get_user_background_images(user_id: str):
         raise HTTPException(status_code=500, detail=f"获取用户背景图片信息失败: {str(e)}")
 
 
+@app.post("/api/users/{user_id}/claim-share-reward")
+async def claim_share_reward(user_id: str):
+    """
+    领取分享奖励
+    - 给用户添加新年背景图片(happynewyear)
+    - 设置用户的当前背景图片为新年的背景
+    
+    Args:
+        user_id: 用户ID
+    
+    Returns:
+        {
+            "success": true,
+            "data": {
+                "user_id": "xxx",
+                "background_image_id": "happynewyear",
+                "message": "分享奖励已领取"
+            }
+        }
+    """
+    try:
+        from database import db_manager
+        from models_db import User, UserBackgroundImage
+        from sqlalchemy import select, update
+        
+        async with db_manager.get_session() as session:
+            # 1. 检查用户是否存在
+            result = await session.execute(
+                select(User).where(User.user_id == user_id)
+            )
+            user = result.scalar_one_or_none()
+            if not user:
+                raise HTTPException(status_code=404, detail="用户不存在")
+            
+            # 2. 检查用户是否已经拥有新年背景
+            bg_result = await session.execute(
+                select(UserBackgroundImage).where(
+                    UserBackgroundImage.user_id == user_id,
+                    UserBackgroundImage.image_id == "happynewyear"
+                )
+            )
+            existing_bg = bg_result.scalar_one_or_none()
+            
+            # 3. 如果还没有,添加到用户背景图片列表
+            if not existing_bg:
+                user_bg_image = UserBackgroundImage(
+                    user_id=user_id,
+                    image_id="happynewyear",
+                    obtained_type="gift",
+                    obtained_source="share_reward"
+                )
+                session.add(user_bg_image)
+                logger.info(f"✅ 为用户添加新年背景图片: user_id={user_id}")
+            else:
+                logger.info(f"⏭️ 用户已拥有新年背景图片: user_id={user_id}")
+            
+            # 4. 更新用户的当前背景图片
+            await session.execute(
+                update(User)
+                .where(User.user_id == user_id)
+                .values(current_background_image_id="happynewyear")
+            )
+            
+            # 5. 获取背景图片的 metadata(用于前端设置透明度等)
+            bg_metadata = None
+            try:
+                from models_db import BackgroundImage
+                bg_info_result = await session.execute(
+                    select(BackgroundImage).where(BackgroundImage.image_id == "happynewyear")
+                )
+                bg_info = bg_info_result.scalar_one_or_none()
+                if bg_info and bg_info.metadata_json:
+                    bg_metadata = bg_info.metadata_json
+            except Exception as e:
+                logger.warning(f"⚠️ 获取背景图片metadata失败: {e}")
+            
+            await session.commit()
+            
+            logger.info(f"✅ 用户领取分享奖励成功: user_id={user_id}, background_image_id=happynewyear")
+            
+            return {
+                "success": True,
+                "data": {
+                    "user_id": user_id,
+                    "background_image_id": "happynewyear",
+                    "background_image_metadata": bg_metadata,
+                    "message": "分享奖励已领取"
+                }
+            }
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"❌ 领取分享奖励失败: user_id={user_id}, error={e}", exc_info=True)
+        raise HTTPException(status_code=500, detail=f"领取分享奖励失败: {str(e)}")
+
+
 @app.get("/api/avatar-gifs")
 async def get_avatar_gifs() -> Dict[str, Any]:
     """

+ 4 - 0
backend/services/user_service.py

@@ -561,6 +561,10 @@ class UserService:
                         state.has_shown_mission_stone_modal = has_shown_mission_stone_modal
                     if has_shown_conversation_stone_modal is not None:
                         state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
+                    if has_clicked_mission_stone is not None:
+                        state.has_clicked_mission_stone = has_clicked_mission_stone
+                    if has_clicked_conversation_stone is not None:
+                        state.has_clicked_conversation_stone = has_clicked_conversation_stone
                     if avatar_c_unlocked is not None:
                         state.avatar_c_unlocked = avatar_c_unlocked
                     if current_avatar_id is not None:

+ 65 - 50
frontend/src/pages/ChatBotPage.tsx

@@ -125,6 +125,7 @@ export const ChatBotPage = () => {
   const sendTimeRef = useRef<number>(0);
   const energyStoneZoneRef = useRef<HTMLDivElement | null>(null);
   const audioWaveRef = useRef<AudioWaveHandle | null>(null);
+  const isMountedRef = useRef(true);
   const [userLevelValue, setUserLevelValue] = useState(() => getUserLevel());
   const levelRedirectRef = useRef(false);
   const [hasSharedAvatar, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
@@ -1134,6 +1135,11 @@ export const ChatBotPage = () => {
         const ws = new WebSocketManager();
         const wsUrl = user_id ? `${WS_URL}?user_id=${encodeURIComponent(user_id)}` : WS_URL;
         ws.connect(wsUrl, (connected) => {
+          // 如果组件已卸载,不执行任何操作
+          if (!isMountedRef.current) {
+            return;
+          }
+          
           const appState = useAppStore.getState();
           const wasConnected = appState.isConnected;
           
@@ -1221,6 +1227,8 @@ export const ChatBotPage = () => {
     init();
 
     return () => {
+      // 标记组件已卸载,防止 WebSocket 回调显示错误消息
+      isMountedRef.current = false;
       recorderRef.current?.release();
       playerRef.current?.release();
       wsRef.current?.disconnect();
@@ -1934,70 +1942,77 @@ export const ChatBotPage = () => {
                       : STORAGE_KEYS.HAS_CLICKED_CONVERSATION_STONE;
                     
                     const hasClicked = getStorage(clickedStorageKey);
-                    if (hasClicked !== 'true') {
-                      // 第一次点击,更新状态
+                    const isFirstClick = hasClicked !== 'true';
+                    
+                    // 检查是否是第一次点击,决定是否显示弹窗
+                    const storageKey = stone.stoneType === 'mission' 
+                      ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL 
+                      : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
+                    const hasShown = getStorage(storageKey);
+                    const isFirstShowModal = hasShown !== 'true';
+                    
+                    // 更新点击状态(控制光晕显示)
+                    if (isFirstClick) {
                       setStorage(clickedStorageKey, 'true');
                       if (stone.stoneType === 'mission') {
                         setHasClickedMissionStone(true);
                       } else {
                         setHasClickedConversationStone(true);
                       }
-                      
-                      // 同步到数据库
-                      const userId = getUserId();
-                      if (userId) {
-                        try {
-                          if (stone.stoneType === 'mission') {
-                            await updateUserUIState(userId, {
-                              has_clicked_mission_stone: true,
-                            });
-                          } else {
-                            await updateUserUIState(userId, {
-                              has_clicked_conversation_stone: true,
-                            });
-                          }
-                        } catch (error) {
-                          console.error('同步能量石点击状态到数据库失败:', error);
-                        }
-                      }
                     }
                     
-                    // 检查是否是第一次点击,决定是否显示弹窗
-                    const storageKey = stone.stoneType === 'mission' 
-                      ? STORAGE_KEYS.HAS_SHOWN_MISSION_STONE_MODAL 
-                      : STORAGE_KEYS.HAS_SHOWN_CONVERSATION_STONE_MODAL;
+                    // 准备需要同步到数据库的字段
+                    const userId = getUserId();
+                    const updatePayload: Record<string, boolean> = {};
                     
-                    const hasShown = getStorage(storageKey);
+                    // 如果是第一次点击,需要更新点击状态
+                    if (isFirstClick) {
+                      if (stone.stoneType === 'mission') {
+                        updatePayload.has_clicked_mission_stone = true;
+                      } else {
+                        updatePayload.has_clicked_conversation_stone = true;
+                      }
+                    }
                     
-                    if (hasShown !== 'true') {
-                      // 第一次点击,显示弹窗
+                    // 如果是第一次显示弹窗,需要更新弹窗状态
+                    if (isFirstShowModal) {
                       setEnergyStoneModalType(stone.stoneType);
                       setShowMissionStoneModal(true);
-                      
-                      // 同步到数据库(成功后再更新 localStorage)
-                      const userId = getUserId();
-                      let synced = false;
-                      if (userId) {
-                        try {
-                          if (stone.stoneType === 'mission') {
-                            await updateUserUIState(userId, {
-                              has_shown_mission_stone_modal: true,
-                            });
-                          } else {
-                            await updateUserUIState(userId, {
-                              has_shown_conversation_stone_modal: true,
-                            });
-                          }
-                          synced = true;
-                        } catch (error) {
-                          console.error('同步能量石弹窗状态到数据库失败:', error);
-                        }
+                      if (stone.stoneType === 'mission') {
+                        updatePayload.has_shown_mission_stone_modal = true;
                       } else {
-                        // 没有 userId(游客态),仅更新本地,等获取 userId 后再补偿
-                        synced = true;
+                        updatePayload.has_shown_conversation_stone_modal = true;
+                      }
+                    }
+                    
+                    // 合并同步到数据库(一次性更新所有需要更新的字段)
+                    if (userId && Object.keys(updatePayload).length > 0) {
+                      try {
+                        await updateUserUIState(userId, updatePayload);
+                        console.log('[能量石] 能量石状态已同步到数据库:', updatePayload);
+                        
+                        // 同步成功后更新 localStorage
+                        if (isFirstShowModal) {
+                          setStorage(storageKey, 'true');
+                        }
+                      } catch (error) {
+                        console.error('同步能量石状态到数据库失败:', error);
+                        // 同步失败时,如果只是点击状态失败,不影响弹窗显示
+                        // 但如果是弹窗状态失败,需要回滚弹窗显示
+                        if (isFirstShowModal) {
+                          // 弹窗已经显示,但同步失败,记录错误以便后续补偿
+                          console.warn('[能量石] 弹窗状态同步失败,但弹窗已显示');
+                        }
+                      }
+                    } else if (!userId) {
+                      // 没有 userId(游客态),仅更新本地
+                      if (isFirstShowModal) {
+                        setStorage(storageKey, 'true');
                       }
-                      
-                      if (synced) {
+                      console.log('[能量石] 游客状态,仅更新本地,等待后续补偿同步');
+                    } else {
+                      // 没有需要更新的字段,直接更新 localStorage(如果显示弹窗)
+                      if (isFirstShowModal) {
                         setStorage(storageKey, 'true');
                       }
                     }

+ 98 - 99
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -1,7 +1,17 @@
 import { useEffect, useRef, useState, type TouchEvent } from 'react';
 import { useNavigate } from 'react-router-dom';
-import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
-import { updateUserUIState } from '@/utils/api';
+import { message } from 'antd';
+import { 
+  setStorage, 
+  STORAGE_KEYS, 
+  getUserId, 
+  getVisitedRoutes,
+  setCurrentBackgroundImageId,
+  setCurrentBackgroundImageUrl,
+  syncBackgroundImageToStorage,
+  setHasSharedAvatar,
+} from '@/utils/storage';
+import { updateUserUIState, claimShareReward } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 
@@ -13,7 +23,7 @@ const closeBtn = getOssUrl('close-btn.png');
 // const wechatIcon = getOssUrl('wechat.png'); // 已注释,未使用
 // const qqIcon = getOssUrl('QQ.png'); // 已注释,未使用
 // const saveIcon = getOssUrl('save.png'); // 已注释,未使用
-const saveBtnImage = getOssUrl('Save-btn.png');
+const saveBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/share-done-claim-gift.png';
 import { shareImages as fallbackShareImages } from '@/data/shareImages';
 import { getShareImages, type ShareImageItem } from '@/utils/api';
 
@@ -75,97 +85,62 @@ export const FirstLoginRewardPage = () => {
     setActiveShareIndex(0);
   };
 
-  // 保存所有图片到相册
+  // 领取分享奖励
   const handleSaveImage = async () => {
     try {
-      if (!shareImages.length) {
+      const userId = getUserId();
+      if (!userId) {
+        message.error('用户未登录,请先登录');
         return;
       }
 
-      // 遍历所有分享图片,逐个下载
-      for (let i = 0; i < shareImages.length; i++) {
-        const imageUrl = shareImages[i]?.url;
-        if (!imageUrl) {
-          continue;
+      message.loading('正在领取分享奖励...', 0);
+
+      // 调用分享奖励接口
+      const result = await claimShareReward(userId);
+      message.destroy();
+
+      if (result.success && result.data) {
+        // 显式更新 localStorage 中的背景图片相关变量
+        const backgroundImageId = result.data.background_image_id || 'happynewyear';
+        const backgroundImageMetadata = result.data.background_image_metadata || { opacity: 0.5 };
+        
+        // 同步背景图片到 localStorage(包括 ID、URL 和 metadata)
+        syncBackgroundImageToStorage(backgroundImageId, backgroundImageMetadata);
+        
+        // 确保背景图片 ID 和 URL 都已更新
+        setCurrentBackgroundImageId(backgroundImageId);
+        
+        // 获取背景图片 URL(从映射中获取)
+        const { getBackgroundImageUrlById } = await import('@/utils/storage');
+        const backgroundImageUrl = getBackgroundImageUrlById(backgroundImageId);
+        if (backgroundImageUrl) {
+          setCurrentBackgroundImageUrl(backgroundImageUrl);
         }
-
+        
+        // 更新分享头像状态:更新 localStorage 和数据库
+        setHasSharedAvatar(true);
         try {
-          // 加载图片
-          const img = new Image();
-          img.crossOrigin = 'anonymous';
-          
-          await new Promise((resolve, reject) => {
-            img.onload = resolve;
-            img.onerror = reject;
-            img.src = imageUrl;
-          });
-
-          // 创建 canvas 并绘制图片
-          const canvas = document.createElement('canvas');
-          const ctx = canvas.getContext('2d');
-          if (!ctx) {
-            console.error(`无法创建画布 (图片 ${i + 1})`);
-            // 如果 Canvas 失败,尝试直接下载
-            const link = document.createElement('a');
-            link.href = imageUrl;
-            link.download = `lyrria-share-${i + 1}-${Date.now()}.png`;
-            document.body.appendChild(link);
-            link.click();
-            document.body.removeChild(link);
-            continue;
-          }
-
-          canvas.width = img.naturalWidth || img.width;
-          canvas.height = img.naturalHeight || img.height;
-          ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
-
-          // 转换为 blob 并下载
-          await new Promise<void>((resolve) => {
-            canvas.toBlob((blob) => {
-              if (blob) {
-                const url = URL.createObjectURL(blob);
-                const link = document.createElement('a');
-                link.href = url;
-                link.download = `lyrria-share-${i + 1}-${Date.now()}.png`;
-                document.body.appendChild(link);
-                link.click();
-                document.body.removeChild(link);
-                URL.revokeObjectURL(url);
-              } else {
-                // 如果 blob 转换失败,尝试直接下载
-                const link = document.createElement('a');
-                link.href = imageUrl;
-                link.download = `lyrria-share-${i + 1}-${Date.now()}.png`;
-                document.body.appendChild(link);
-                link.click();
-                document.body.removeChild(link);
-              }
-              // 添加延迟,避免浏览器阻止多个下载
-              setTimeout(resolve, 300);
-            }, 'image/png');
+          await updateUserUIState(userId, {
+            has_shared_avatar: true,
           });
         } catch (error) {
-          console.error(`保存图片 ${i + 1} 失败:`, error);
-          // 备用方案:直接下载图片
-          try {
-            const link = document.createElement('a');
-            link.href = imageUrl;
-            link.download = `lyrria-share-${i + 1}-${Date.now()}.png`;
-            document.body.appendChild(link);
-            link.click();
-            document.body.removeChild(link);
-            // 添加延迟
-            await new Promise(resolve => setTimeout(resolve, 300));
-          } catch (fallbackError) {
-            console.error(`备用保存方案也失败 (图片 ${i + 1}):`, fallbackError);
-          }
+          console.error('同步分享头像状态到数据库失败:', error);
+          // 即使同步失败,localStorage 已更新,不影响主流程
         }
+        
+        message.success('分享奖励已领取!新年背景已解锁');
+        // 跳转到分享成功页面
+        setTimeout(() => {
+          navigate('/share-success');
+        }, 1000);
+      } else {
+        message.error(result.message || '领取分享奖励失败,请重试');
       }
-
-      // 所有图片保存成功后跳转到 ShareSuccessPage
-      navigate('/share-success');
     } catch (error) {
-      console.error('保存图片过程出错:', error);
+      console.error('领取分享奖励失败:', error);
+      message.destroy();
+      message.error('领取分享奖励失败,请重试');
     }
   };
 
@@ -351,8 +326,8 @@ export const FirstLoginRewardPage = () => {
                   position: 'absolute',
                   top: px2vmin(62),
                   right: `calc((100% - ${px2vmin(311)}) / 2 - ${px2vmin(5)})`,
-                  width: px2vmin(42),
-                  height: px2vmin(42),
+                  width: px2vmin(30),
+                  height: px2vmin(30),
                   padding: 0,
                   border: 'none',
                   background: 'none',
@@ -427,7 +402,6 @@ export const FirstLoginRewardPage = () => {
                   <img
                     src={shareImages[activeShareIndex]?.url}
                     alt="Share preview"
-                    onClick={handleShareClose}
                     style={{
                       width: '100%',
                       height: 'auto',
@@ -478,30 +452,55 @@ export const FirstLoginRewardPage = () => {
 
             {/* 保存按钮 */}
             {shareImages.length > 0 && (
-              <button
-                type="button"
-                onClick={handleSaveImage}
+              <div
                 style={{
                   position: 'absolute',
                   left: '50%',
                   top: px2vmin(670),
                   transform: 'translateX(-50%)',
-                  border: 'none',
-                  padding: 0,
-                  background: 'none',
-                  width: px2vmin(116),
-                  cursor: 'pointer',
+                  width: px2vmin(211),
+                  display: 'flex',
+                  flexDirection: 'column',
+                  alignItems: 'center',
                 }}
               >
-                <img
-                  src={saveBtnImage}
-                  alt="Save"
+                <button
+                  type="button"
+                  onClick={handleSaveImage}
                   style={{
+                    border: 'none',
+                    padding: 0,
+                    background: 'none',
                     width: '100%',
-                    display: 'block',
+                    cursor: 'pointer',
                   }}
-                />
-              </button>
+                >
+                  <img
+                    src={saveBtnImage}
+                    alt="Save"
+                    style={{
+                      width: '100%',
+                      display: 'block',
+                    }}
+                  />
+                </button>
+                <div
+                  style={{
+                    marginTop: px2vmin(12),
+                    width: px2vmin(311),
+                    color: '#FFFFFF',
+                    fontSize: px2vmin(12),
+                    textAlign: 'center',
+                    lineHeight: px2vmin(14),
+                    wordWrap: 'break-word',
+                    overflowWrap: 'break-word',
+                  }}
+                >
+                  长按保存精彩图片,或使用浏览器分享,邀请好友一起加入学习旅程!
+                  <br />
+                  Long-press to save the image or share via your browser. Invite friends to join your learning journey!
+                </div>
+              </div>
             )}
 
               {/* 底部分享按钮区域 - 已注释 */}

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

@@ -302,7 +302,7 @@ export const GreetingPage = () => {
                 paddingLeft: px2vw(24),
                 paddingRight: px2vw(48),
               }}
-              placeholder={placeholderName || 'Coco'}
+              placeholder={placeholderName || ''}
               value={userName}
               onChange={(e) => {
                 setHasUserEdited(true);
@@ -323,7 +323,7 @@ export const GreetingPage = () => {
                 }
               }}
             />
-            {userName && (
+            {isFocused && userName && (
               <button
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity hover:opacity-70 touch-manipulation"
                 style={{

+ 1 - 1
frontend/src/pages/LumiNamingPage.tsx

@@ -205,7 +205,7 @@ export const LumiNamingPage = () => {
                 }
               }}
             />
-            {lumiName && (
+            {isFocused && lumiName && (
               <button
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity hover:opacity-70 touch-manipulation"
                 style={{

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

@@ -625,3 +625,45 @@ export const getShareImages = async (): Promise<GetShareImagesResponse> => {
   return response.json();
 };
 
+/**
+ * 领取分享奖励
+ */
+export interface ClaimShareRewardResponse {
+  success: boolean;
+  data?: {
+    user_id: string;
+    background_image_id: string;
+    background_image_metadata?: Record<string, any>;
+    message: string;
+  };
+  message?: string;
+}
+
+export const claimShareReward = async (user_id: string): Promise<ClaimShareRewardResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/claim-share-reward`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+  });
+
+  if (!response.ok) {
+    const errorText = await response.text().catch(() => '领取分享奖励失败');
+    throw new Error(errorText);
+  }
+
+  const data = await response.json();
+  
+  // 如果成功,同步背景图片到 localStorage
+  if (data.success && data.data?.background_image_id) {
+    const { syncBackgroundImageToStorage } = await import('./storage');
+    syncBackgroundImageToStorage(
+      data.data.background_image_id,
+      data.data.background_image_metadata || { opacity: 0.5 } // 使用后端返回的metadata,或默认透明度
+    );
+  }
+  
+  return data;
+};
+