zlwu 11 місяців тому
батько
коміт
19863b2467

+ 102 - 1
backend/services/user_service.py

@@ -10,10 +10,14 @@ from datetime import datetime
 from sqlalchemy import select, update, func, insert
 from sqlalchemy.ext.asyncio import AsyncSession
 from database import db_manager
-from models_db import User, UserUIState, UserBackgroundImage, BackgroundImage
+from models_db import User, UserUIState, UserBackgroundImage, BackgroundImage, UserAvatar
 
 logger = logging.getLogger(__name__)
 
+# 默认头像 ID
+DEFAULT_AVATAR_ID = "avatar_default"
+REGISTERED_AVATAR_ID = "avatar_registered_cap"
+
 LEVEL_THRESHOLDS_MS: Tuple[Tuple[int, int], ...] = (
     (5, 240_000),
     (4, 120_000),
@@ -72,6 +76,58 @@ class UserService:
             # 背景图片初始化失败不应该影响用户创建,只记录日志
             logger.warning(f"⚠️ 初始化用户背景图片失败: user_id={user_id}, error={e}")
     
+    async def _init_user_avatar(self, session: AsyncSession, user_id: str, user_type: str = "guest") -> None:
+        """
+        初始化用户头像(添加到已获得列表)
+        
+        Args:
+            session: 数据库会话
+            user_id: 用户ID
+            user_type: 用户类型(guest 或 registered)
+        """
+        try:
+            # 游客用户只分配默认头像
+            # 注册用户同时分配默认头像和注册赠送头像
+            avatars_to_add = []
+            
+            if user_type == "registered":
+                # 注册用户:默认头像 + 注册赠送头像
+                avatars_to_add = [
+                    (DEFAULT_AVATAR_ID, "default", "system_init"),
+                    (REGISTERED_AVATAR_ID, "register", "register_bonus"),
+                ]
+            else:
+                # 游客用户:只分配默认头像
+                avatars_to_add = [
+                    (DEFAULT_AVATAR_ID, "default", "system_init"),
+                ]
+            
+            for avatar_id, obtained_type, obtained_source in avatars_to_add:
+                # 检查是否已存在,避免重复添加
+                result = await session.execute(
+                    select(UserAvatar).where(
+                        UserAvatar.user_id == user_id,
+                        UserAvatar.avatar_id == avatar_id
+                    )
+                )
+                existing = result.scalar_one_or_none()
+                
+                if not existing:
+                    user_avatar = UserAvatar(
+                        user_id=user_id,
+                        avatar_id=avatar_id,
+                        obtained_type=obtained_type,
+                        obtained_source=obtained_source
+                    )
+                    session.add(user_avatar)
+                    logger.debug(f"✅ 为用户初始化头像: user_id={user_id}, avatar_id={avatar_id}, user_type={user_type}")
+                else:
+                    logger.debug(f"⏭️ 用户已拥有头像: user_id={user_id}, avatar_id={avatar_id}")
+                
+        except Exception as e:
+            # 头像初始化失败不应该影响用户创建,只记录日志
+            logger.warning(f"⚠️ 初始化用户头像失败: user_id={user_id}, error={e}")
+    
     async def _generate_temp_username(self, session: AsyncSession) -> str:
         """
         生成临时用户名(格式:User + 7位随机数字)
@@ -163,9 +219,18 @@ class UserService:
                 # 初始化背景图片
                 await self._init_user_background_image(session, user_id)
                 
+                # 初始化默认头像
+                await self._init_user_avatar(session, user_id, user_type="guest")
+                
                 await session.commit()
                 await session.refresh(user)
             
+            # 初始化 user_ui_states 的 current_avatar_id(游客用户使用默认头像)
+            await self.upsert_user_ui_state(
+                user_id=user_id,
+                current_avatar_id=DEFAULT_AVATAR_ID
+            )
+            
             logger.info(f"✅ 创建游客用户成功: user_id={user_id}, username={username}, bot_name={bot_name}")
             
             return {
@@ -562,9 +627,18 @@ class UserService:
                 # 初始化背景图片
                 await self._init_user_background_image(session, user_id)
                 
+                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                await self._init_user_avatar(session, user_id, user_type="registered")
+                
                 await session.commit()
                 await session.refresh(user)
             
+            # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            await self.upsert_user_ui_state(
+                user_id=user_id,
+                current_avatar_id=REGISTERED_AVATAR_ID
+            )
+            
             logger.info(f"✅ 手机号注册成功: user_id={user_id}, phone={phone}")
             return user.to_dict()
         except Exception as e:
@@ -613,9 +687,18 @@ class UserService:
                 # 初始化背景图片
                 await self._init_user_background_image(session, user_id)
                 
+                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                await self._init_user_avatar(session, user_id, user_type="registered")
+                
                 await session.commit()
                 await session.refresh(user)
             
+            # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            await self.upsert_user_ui_state(
+                user_id=user_id,
+                current_avatar_id=REGISTERED_AVATAR_ID
+            )
+            
             logger.info(f"✅ 邮箱注册成功: user_id={user_id}, email={email}")
             return user.to_dict()
         except Exception as e:
@@ -666,9 +749,18 @@ class UserService:
                 # 初始化背景图片
                 await self._init_user_background_image(session, user_id)
                 
+                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                await self._init_user_avatar(session, user_id, user_type="registered")
+                
                 await session.commit()
                 await session.refresh(user)
             
+            # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            await self.upsert_user_ui_state(
+                user_id=user_id,
+                current_avatar_id=REGISTERED_AVATAR_ID
+            )
+            
             logger.info(f"✅ 微信注册成功: user_id={user_id}, openid={openid}")
             return user.to_dict()
         except Exception as e:
@@ -751,9 +843,18 @@ class UserService:
                 # 初始化背景图片
                 await self._init_user_background_image(session, user_id)
                 
+                # 初始化头像(注册用户:默认头像 + 注册赠送头像)
+                await self._init_user_avatar(session, user_id, user_type="registered")
+                
                 await session.commit()
                 await session.refresh(user)
             
+            # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
+            await self.upsert_user_ui_state(
+                user_id=user_id,
+                current_avatar_id=REGISTERED_AVATAR_ID
+            )
+            
             logger.info(
                 f"✅ QQ注册成功: user_id={user_id}, openid={openid}, unionid={unionid}"
             )

+ 1 - 1
frontend/src/hooks/useEnergyStones.ts

@@ -6,7 +6,7 @@ const DEFAULT_STONE_SIZES: Record<'mission' | 'conversation', number> = {
   mission: 86,
   conversation: 68,
 };
-const FADE_DURATION_MS = 600;
+const FADE_DURATION_MS = 1500;
 
 interface StonePosition {
   xRatio: number;

+ 41 - 5
frontend/src/pages/ChatBotPage.tsx

@@ -781,6 +781,30 @@ export const ChatBotPage = () => {
     }
   }, []);
 
+  // 监听能量石的 fading 状态,当开始消失动画时,计算当前位置并设置为固定定位
+  useEffect(() => {
+    energyStones.forEach((stone) => {
+      if (stone.fading) {
+        // 使用 requestAnimationFrame 确保 DOM 更新后再设置位置
+        requestAnimationFrame(() => {
+          const element = document.querySelector(`[data-stone-id="${stone.stoneId}"]`) as HTMLElement;
+          if (element) {
+            const rect = element.getBoundingClientRect();
+            // 计算能量石中心的屏幕位置
+            const centerX = rect.left + rect.width / 2;
+            const centerY = rect.top + rect.height / 2;
+            // 设置为固定定位,并设置起始位置
+            element.style.position = 'fixed';
+            element.style.left = `${centerX}px`;
+            element.style.top = `${centerY}px`;
+            element.style.transform = 'translate(-50%, -50%) scale(1)';
+            element.style.opacity = '1';
+          }
+        });
+      }
+    });
+  }, [energyStones]);
+
   // 监听 localStorage 中背景图片的变化
   useEffect(() => {
     // 监听 storage 事件(其他标签页/窗口的变化)
@@ -1700,6 +1724,9 @@ export const ChatBotPage = () => {
             return (
               <div
                 key={stone.stoneId}
+                data-stone-id={stone.stoneId}
+                data-fading={stone.fading ? 'true' : 'false'}
+                className={`energy-stone-container ${stone.stoneType === 'mission' ? 'energy-stone-mission' : ''}`}
                 style={{
                   position: 'absolute',
                   left: `${(stone.position.xRatio * 100).toFixed(2)}%`,
@@ -1710,8 +1737,8 @@ export const ChatBotPage = () => {
                   pointerEvents: 'none',
                 }}
               >
-                {/* 光晕层 - 只在未点击时显示 */}
-                {shouldShowHint && !stone.fading && (
+                {/* 光晕层 - 只在未点击时显示,但在动画过程中也显示以跟随能量石 */}
+                {shouldShowHint && (
                   <img
                     src={hintAsset}
                     alt=""
@@ -1721,10 +1748,15 @@ export const ChatBotPage = () => {
                       top: '50%',
                       left: '50%',
                       transform: 'translate(-50%, -50%)',
+                      transformOrigin: 'center center',
                       width: px2vmin(hintSize),
                       height: px2vmin(hintSize),
                       pointerEvents: 'none',
                       zIndex: 0,
+                      opacity: stone.fading ? 0 : undefined, // 动画过程中逐渐消失
+                      maxWidth: 'none',
+                      maxHeight: 'none',
+                      objectFit: 'contain',
                     }}
                   />
                 )}
@@ -1735,11 +1767,15 @@ export const ChatBotPage = () => {
                   data-type={stone.stoneType}
                   data-fading={stone.fading ? 'true' : 'false'}
                   style={{
+                    position: 'absolute',
+                    top: '50%',
+                    left: '50%',
+                    transform: 'translate(-50%, -50%)',
+                    transformOrigin: 'center center',
                     width: px2vmin(stoneSize),
                     height: px2vmin(stoneSize),
-                    position: 'relative',
-                    zIndex: 1,
-                    pointerEvents: 'auto',
+                    zIndex: stone.fading ? 100 : 1,
+                    pointerEvents: stone.fading ? 'none' : 'auto',
                   }}
                   onClick={async () => {
                     handleStoneClick(stone.stoneId);

+ 27 - 2
frontend/src/pages/ProfilePage.tsx

@@ -4,6 +4,7 @@ import { px2vmin, px2vmax, px2vw } from '@/utils/viewport';
 import { getUserInfo, updateUsername, updateBotName } from '@/utils/api';
 import { getUserId, getStorage, setStorage, STORAGE_KEYS } from '@/utils/storage';
 import { getOssUrl } from '@/utils/oss';
+import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
 
 const closeBtn = getOssUrl('close-btn.png');
 const editIcon = getOssUrl('mode_edit_outline.png');
@@ -35,6 +36,8 @@ export const ProfilePage = () => {
   const [tempBotName, setTempBotName] = useState<string>('');
   const usernameInputRef = useRef<HTMLInputElement>(null);
   const botNameInputRef = useRef<HTMLInputElement>(null);
+  const [showEnergyStoneModal, setShowEnergyStoneModal] = useState<boolean>(false);
+  const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
 
   // 获取能量石数量
   const fetchEnergyStoneCounts = useCallback(async (userId: string) => {
@@ -153,7 +156,7 @@ export const ProfilePage = () => {
 
   // 防止弹窗打开时背景滚动
   useEffect(() => {
-    if (showUsernameModal || showBotNameModal) {
+    if (showUsernameModal || showBotNameModal || showEnergyStoneModal) {
       document.body.style.overflow = 'hidden';
     } else {
       document.body.style.overflow = '';
@@ -162,7 +165,7 @@ export const ProfilePage = () => {
     return () => {
       document.body.style.overflow = '';
     };
-  }, [showUsernameModal, showBotNameModal]);
+  }, [showUsernameModal, showBotNameModal, showEnergyStoneModal]);
 
   const handleClose = useCallback(() => {
     navigate(-1);
@@ -252,6 +255,17 @@ export const ProfilePage = () => {
     setTempBotName('');
   }, []);
 
+  // 处理能量石点击
+  const handleEnergyStoneClick = useCallback((stoneType: 'mission' | 'conversation') => {
+    setEnergyStoneModalType(stoneType);
+    setShowEnergyStoneModal(true);
+  }, []);
+
+  // 关闭能量石弹窗
+  const handleCloseEnergyStoneModal = useCallback(() => {
+    setShowEnergyStoneModal(false);
+  }, []);
+
   // 计算升级所需时间和当前进度
   const getLevelProgress = useCallback(() => {
     const level = userLevel;
@@ -384,10 +398,12 @@ export const ProfilePage = () => {
       >
         {/* 任务能量石 */}
         <div
+          onClick={() => handleEnergyStoneClick('mission')}
           style={{
             position: 'relative',
             width: px2vmin(88),
             height: px2vmin(44),
+            cursor: 'pointer',
           }}
         >
           <img
@@ -415,10 +431,12 @@ export const ProfilePage = () => {
 
         {/* 对话能量石 */}
         <div
+          onClick={() => handleEnergyStoneClick('conversation')}
           style={{
             position: 'relative',
             width: px2vmin(88),
             height: px2vmin(44),
+            cursor: 'pointer',
           }}
         >
           <img
@@ -1122,6 +1140,13 @@ export const ProfilePage = () => {
           </div>
         </>
       )}
+
+      {/* 能量石弹窗 */}
+      <MissionEnergyStoneModal
+        visible={showEnergyStoneModal}
+        onClose={handleCloseEnergyStoneModal}
+        stoneType={energyStoneModalType}
+      />
     </div>
   );
 };

+ 2 - 2
frontend/src/pages/WelcomePage.css

@@ -467,8 +467,8 @@ video::-webkit-media-controls-overlay-play-button {
 }
 
 .agreement-checkbox.checked {
-  background: rgba(191, 157, 248, 1);
-  border-color: rgba(191, 157, 248, 1);
+  background: rgb(145 89 240);
+  border-color: rgb(145 89 240);
 }
 
 .agreement-checkbox.checked::after {

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

@@ -21,7 +21,7 @@ export const WelcomePage = () => {
   const playerRef = useRef<VideoJsPlayer | null>(null);
   const [viewState, setViewState] = useState<WelcomeViewState>('welcome');
   const [isLoading, setIsLoading] = useState(false);
-  const [agreementChecked, setAgreementChecked] = useState(false);
+  const [agreementChecked, setAgreementChecked] = useState(true);
   const isVideoReadyRef = useRef(false); // 视频是否已加载完成
   const loadingStartTimeRef = useRef<number | null>(null); // 加载开始时间
   const userInteractionRef = useRef(false); // 用户是否已交互(用于确保声音播放)

+ 117 - 14
frontend/src/styles/chatbot-fix.css

@@ -43,9 +43,10 @@ body[data-too-small="true"] .chatbot-bg,
   padding: 0;
   cursor: pointer;
   pointer-events: auto;
-  transition: opacity 0.45s ease, transform 0.45s ease;
+  transition: opacity 1.5s ease-out, transform 1.5s ease-out;
   filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.45));
-  animation: energyStoneFloat 6s ease-in-out infinite;
+  /* 移除浮动动画,因为现在由容器处理 */
+  animation: none;
 }
 
 .energy-stone img {
@@ -59,20 +60,80 @@ body[data-too-small="true"] .chatbot-bg,
   animation-duration: 8s;
 }
 
-.energy-stone[data-fading='true'] {
-  opacity: 0;
-  transform: scale(0.65);
+/* 能量石容器:应用浮动动画,让光晕和能量石按钮一起浮动 */
+.energy-stone-container {
+  animation: energyStoneContainerFloat 6s ease-in-out infinite;
+}
+
+.energy-stone-container.energy-stone-mission {
+  animation-duration: 8s;
+}
+
+@keyframes energyStoneContainerFloat {
+  0% {
+    transform: translate(-50%, -50%) translateY(0);
+  }
+  50% {
+    transform: translate(-50%, -50%) translateY(-10px);
+  }
+  100% {
+    transform: translate(-50%, -50%) translateY(0);
+  }
+}
+
+/* 能量石容器在消失动画时 */
+div[data-fading='true'] {
+  position: fixed !important;
+  animation: energyStoneFadeToAvatar 1.5s ease-in-out forwards !important;
+  z-index: 10000 !important;
+  pointer-events: none !important;
+}
+
+/* 动画中的能量石,禁用浮动动画 */
+div[data-fading='true'] .energy-stone {
+  animation: none !important;
+}
+
+/* 动画中的能量石,禁用光晕呼吸灯动画,让光晕跟随能量石一起移动和缩小 */
+div[data-fading='true'] .energy-stone-hint {
+  animation: none !important;
+  /* 光晕跟随能量石一起缩小和移动,保持居中 */
+  transform: translate(-50%, -50%) !important;
+}
+
+/* 确保能量石按钮和光晕在动画过程中都保持居中 */
+div[data-fading='true'] .energy-stone {
+  transform: translate(-50%, -50%) !important;
+}
+
+@keyframes energyStoneFadeToAvatar {
+  0% {
+    /* 起始位置:保持当前 transform 和位置 */
+    transform: translate(-50%, -50%) scale(1);
+    opacity: 1;
+  }
+  100% {
+    /* 目标位置:动画形象中心 */
+    /* 设计稿:top 248px + height/2 (340/2=170px) = 418px */
+    /* 使用与动画形象相同的 px2vmin 计算方式 */
+    /* px2vmin(418) = min(calc(418 * min(100vw, 600PX) / 375), calc(418 / 812 * 100 * var(--vh, 1vh))) */
+    /* = min(calc(418 * min(100vw, 600PX) / 375), calc(51.48 * var(--vh, 1vh))) */
+    top: min(calc(418 * min(100vw, 600PX) / 375), calc(51.48 * var(--vh, 1vh)));
+    left: 50%;
+    transform: translate(-50%, -50%) scale(0.1);
+    opacity: 0;
+  }
 }
 
 @keyframes energyStoneFloat {
   0% {
-    transform: translateY(0);
+    transform: translate(-50%, -50%) translateY(0);
   }
   50% {
-    transform: translateY(-10px);
+    transform: translate(-50%, -50%) translateY(-10px);
   }
   100% {
-    transform: translateY(0);
+    transform: translate(-50%, -50%) translateY(0);
   }
 }
 
@@ -114,18 +175,60 @@ body[data-too-small="true"] .chatbot-bg,
   animation: energyStoneHintPulse 2s ease-in-out infinite;
 }
 
+/* 当光晕在 energy-stone 容器内时,只需要缩放和透明度动画 */
+/* 浮动效果由容器处理,确保光晕和能量石按钮完全同步 */
+.energy-stone .energy-stone-hint {
+  animation: energyStoneHintPulseScale 2s ease-in-out infinite;
+}
+
 @keyframes energyStoneHintPulse {
   0% {
-    opacity: 0.4;
-    transform: translate(-50%, -50%) scale(0.95);
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
   }
   50% {
-    opacity: 0.8;
-    transform: translate(-50%, -50%) scale(1.05);
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1);
+  }
+  100% {
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
+  }
+}
+
+/* 光晕的呼吸灯和缩放动画,同时跟随能量石按钮的浮动效果 */
+/* transform 必须包含 translate(-50%, -50%) 保持居中,以及 scale 缩放效果 */
+/* 光晕和能量石按钮都使用相同的基准点(50%, 50%),确保中心一致 */
+@keyframes energyStoneHintPulseWithScale {
+  0% {
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
+  }
+  50% {
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1);
+  }
+  100% {
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
+  }
+}
+
+/* 光晕的呼吸灯动画(只包含透明度和缩放) */
+/* 浮动效果由容器处理,确保光晕和能量石按钮完全同步 */
+/* transform 必须包含 translate(-50%, -50%) 保持居中 */
+@keyframes energyStoneHintPulseScale {
+  0% {
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
+  }
+  50% {
+    opacity: 1;
+    transform: translate(-50%, -50%) scale(1);
   }
   100% {
-    opacity: 0.4;
-    transform: translate(-50%, -50%) scale(0.95);
+    opacity: 0.6;
+    transform: translate(-50%, -50%) scale(0.7);
   }
 }