zlwu 10 月之前
父節點
當前提交
e0d418603f

+ 100 - 1
backend/main.py

@@ -24,7 +24,7 @@ from services.redis_cache_service import RedisCacheService
 from services.user_service import user_service
 from services.wechat_jsapi_service import WechatJSAPIService
 from services.energy_stone_service import energy_stone_service
-from models_db import VerificationCode, UserAvatar, UserBackgroundImage
+from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback
 
 # 配置日志
 logging.basicConfig(
@@ -112,6 +112,11 @@ class UpdateBotNameRequest(BaseModel):
     bot_name: str
 
 
+class UpdateBackgroundImageIdRequest(BaseModel):
+    """更新背景图片ID请求"""
+    background_image_id: str
+
+
 class RegisterPhoneRequest(BaseModel):
     """手机号注册请求"""
     area_code: Optional[str] = None
@@ -634,6 +639,34 @@ async def update_username(user_id: str, request: UpdateUsernameRequest):
         raise HTTPException(status_code=500, detail=f"更新用户名失败: {str(e)}")
 
 
+@app.put("/api/users/{user_id}/background-image-id")
+async def update_background_image_id(user_id: str, request: UpdateBackgroundImageIdRequest):
+    """
+    更新用户当前使用的背景图片ID
+    
+    Args:
+        user_id: 用户ID
+        request: 更新请求(包含background_image_id)
+    
+    Returns:
+        {
+            "success": true,
+            "message": "更新成功"
+        }
+    """
+    try:
+        success = await user_service.update_current_background_image_id(
+            user_id, request.background_image_id
+        )
+        if not success:
+            raise HTTPException(status_code=404, detail="用户不存在或更新失败")
+        return {"success": True, "message": "更新成功"}
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"更新背景图片ID失败: {str(e)}") from e
+
+
 @app.put("/api/users/{user_id}/bot-name")
 async def update_bot_name(user_id: str, request: UpdateBotNameRequest):
     """
@@ -691,6 +724,72 @@ async def update_bot_name(user_id: str, request: UpdateBotNameRequest):
         )
 
 
+class SubmitFeedbackRequest(BaseModel):
+    """提交反馈请求"""
+    content: str
+
+
+@app.post("/api/users/{user_id}/feedback")
+async def submit_feedback(user_id: str, request: SubmitFeedbackRequest):
+    """
+    提交用户反馈
+    
+    Args:
+        user_id: 用户ID
+        request: 反馈请求(包含content)
+    
+    Returns:
+        {
+            "success": true,
+            "data": {
+                "feedback_id": "uuid",
+                "user_id": "uuid",
+                "content": "反馈内容",
+                "status": "pending",
+                "created_at": "2024-01-01T00:00:00"
+            }
+        }
+    """
+    try:
+        # 验证反馈内容
+        if not request.content or not request.content.strip():
+            raise HTTPException(status_code=400, detail="反馈内容不能为空")
+        
+        # 验证用户是否存在
+        async with db_manager.get_session() as session:
+            from models_db import User
+            user_result = await session.execute(
+                select(User).where(User.user_id == user_id)
+            )
+            user = user_result.scalar_one_or_none()
+            if not user:
+                raise HTTPException(status_code=404, detail="用户不存在")
+            
+            # 创建反馈记录
+            feedback_id = str(uuid.uuid4())
+            feedback = Feedback(
+                feedback_id=feedback_id,
+                user_id=user_id,
+                content=request.content.strip(),
+                status="pending"
+            )
+            session.add(feedback)
+            await session.commit()
+            await session.refresh(feedback)
+            
+            logger.info(f"✅ 用户反馈提交成功: user_id={user_id}, feedback_id={feedback_id}")
+            
+            return {
+                "success": True,
+                "data": feedback.to_dict()
+            }
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"❌ 提交反馈失败: user_id={user_id}, error={e}")
+        raise HTTPException(status_code=500, detail=f"提交反馈失败: {str(e)}")
+
+
 @app.post("/api/auth/register/phone")
 async def register_with_phone(request: RegisterPhoneRequest):
     """

+ 93 - 0
backend/migrate_add_feedbacks_table.py

@@ -0,0 +1,93 @@
+"""
+数据库迁移脚本:创建反馈表
+运行: python migrate_add_feedbacks_table.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_feedbacks_table() -> bool:
+    """创建反馈表"""
+    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 = 'feedbacks'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            table_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not table_exists:
+                print("🔄 创建 feedbacks 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE feedbacks (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        feedback_id VARCHAR(64) NOT NULL UNIQUE COMMENT '反馈ID',
+                        user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
+                        
+                        -- 反馈内容
+                        content TEXT NOT NULL COMMENT '反馈内容',
+                        
+                        -- 状态信息
+                        status ENUM('pending', 'reviewed', 'resolved', 'closed') NOT NULL DEFAULT 'pending' COMMENT '反馈状态',
+                        
+                        -- 扩展信息
+                        metadata JSON NULL COMMENT '扩展信息',
+                        
+                        -- 时间信息
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                        
+                        INDEX idx_feedback_id (feedback_id),
+                        INDEX idx_user_id (user_id),
+                        INDEX idx_feedback_user_created (user_id, created_at)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+                    COMMENT='用户反馈表'
+                    """
+                )
+                await conn.commit()
+                print("✅ feedbacks 表创建完成")
+            else:
+                print("✅ feedbacks 表已存在")
+
+        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_create_feedbacks_table())
+    if not ok:
+        sys.exit(1)
+

+ 47 - 0
backend/models_db.py

@@ -439,6 +439,53 @@ class UserAvatar(Base):
         }
 
 
+class Feedback(Base):
+    """用户反馈表模型"""
+    __tablename__ = "feedbacks"
+    
+    id = Column(PKBigInt, primary_key=True, autoincrement=True)
+    feedback_id = Column(String(64), unique=True, nullable=False, index=True, comment="反馈ID")
+    user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
+    
+    # 反馈内容
+    content = Column(Text, nullable=False, comment="反馈内容")
+    
+    # 状态信息
+    status = Column(
+        Enum("pending", "reviewed", "resolved", "closed", name="feedback_status"),
+        default="pending",
+        nullable=False,
+        comment="反馈状态"
+    )
+    
+    # 扩展信息
+    metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
+    
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), index=True, comment="创建时间")
+    updated_at = Column(
+        TIMESTAMP,
+        server_default=func.current_timestamp(),
+        onupdate=func.current_timestamp(),
+        comment="更新时间",
+    )
+    
+    __table_args__ = (
+        Index("idx_feedback_user_created", "user_id", "created_at"),
+    )
+    
+    def to_dict(self) -> dict:
+        """转换为字典"""
+        return {
+            "feedback_id": self.feedback_id,
+            "user_id": self.user_id,
+            "content": self.content,
+            "status": self.status,
+            "metadata": self.metadata_json,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+
 def _attach_metadata_alias(model_cls):
     """Allow using `metadata` keyword/attribute while storing in metadata_json."""
     original_init = model_cls.__init__

+ 28 - 0
backend/services/user_service.py

@@ -335,6 +335,34 @@ class UserService:
             logger.error(f"❌ 更新用户名失败: user_id={user_id}, error={e}")
             return False
     
+    async def update_current_background_image_id(self, user_id: str, background_image_id: str) -> bool:
+        """
+        更新用户当前使用的背景图片ID
+        
+        Args:
+            user_id: 用户ID
+            background_image_id: 背景图片ID
+        
+        Returns:
+            是否更新成功
+        """
+        try:
+            async with db_manager.get_session() as session:
+                result = await session.execute(
+                    update(User)
+                    .where(User.user_id == user_id)
+                    .values(current_background_image_id=background_image_id)
+                )
+                await session.commit()
+                
+                if result.rowcount > 0:
+                    logger.info(f"✅ 更新用户背景图片ID成功: user_id={user_id}, background_image_id={background_image_id}")
+                    return True
+                return False
+        except Exception as e:
+            logger.error(f"❌ 更新用户背景图片ID失败: user_id={user_id}, error={e}")
+            return False
+    
     async def update_bot_name(self, user_id: str, bot_name: str) -> bool:
         """
         更新用户给机器人取的名字

+ 160 - 199
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -24,6 +24,7 @@ const closeBtn = getOssUrl('close-btn.png');
 // const qqIcon = getOssUrl('QQ.png'); // 已注释,未使用
 // const saveIcon = getOssUrl('save.png'); // 已注释,未使用
 const saveBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/share-done-claim-gift.png';
+const claimGiftBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/claim-the-gift.png';
 import { shareImages as fallbackShareImages } from '@/data/shareImages';
 import { getShareImages, type ShareImageItem } from '@/utils/api';
 
@@ -299,28 +300,26 @@ export const FirstLoginRewardPage = () => {
       )}
 
       {showShareModal && (
-        <div
-          style={{
-            position: 'fixed',
-            inset: 0,
-            zIndex: 2000,
-            backgroundColor: 'rgba(0, 0, 0, 0.4)',
-          }}
-        >
+        <>
+          {/* 分享图片区域 - 在弹窗上方显示 */}
           <div
             style={{
-              position: 'relative',
-              width: '100%',
-              height: '100%',
+              position: 'fixed',
+              inset: 0,
+              zIndex: 9997,
               display: 'flex',
               justifyContent: 'center',
+              alignItems: 'flex-start',
+              paddingTop: px2vmin(118),
+              pointerEvents: 'none',
             }}
           >
             <div
               style={{
                 position: 'relative',
                 width: px2vw(375),
-                height: '100%',
+                display: 'flex',
+                justifyContent: 'center',
               }}
             >
               <button
@@ -328,13 +327,15 @@ export const FirstLoginRewardPage = () => {
                 onClick={handleShareClose}
                 style={{
                   position: 'absolute',
-                  top: px2vmin(62),
+                  top: px2vmin(-56),
                   right: `calc((100% - ${px2vmin(311)}) / 2 - ${px2vmin(5)})`,
                   width: px2vmin(30),
                   height: px2vmin(30),
                   padding: 0,
                   border: 'none',
                   background: 'none',
+                  pointerEvents: 'auto',
+                  zIndex: 10000,
                 }}
                 aria-label="Close share modal"
               >
@@ -345,129 +346,170 @@ export const FirstLoginRewardPage = () => {
                 />
               </button>
 
-            {shareImages.length > 0 && (
-              <div
-                onTouchStart={handleTouchStart}
-                onTouchEnd={handleTouchEnd}
-                style={{
-                  position: 'absolute',
-                  top: px2vmin(118),
-                  left: '50%',
-                  transform: 'translateX(-50%)',
-                  width: px2vmin(311),
-                  display: 'flex',
-                  justifyContent: 'center',
-                  overflow: 'visible',
-                }}
-              >
+              {shareImages.length > 0 && (
                 <div
+                  onTouchStart={handleTouchStart}
+                  onTouchEnd={handleTouchEnd}
                   style={{
-                    position: 'relative',
-                    width: '100%',
+                    width: px2vmin(311),
                     display: 'flex',
                     justifyContent: 'center',
                     overflow: 'visible',
+                    pointerEvents: 'auto',
                   }}
                 >
-                  {activeShareIndex > 0 && (
-                    <img
-                      src={shareImages[activeShareIndex - 1]?.url}
-                      alt="Previous share preview"
-                      aria-hidden
-                      style={{
-                        position: 'absolute',
-                        height: 'auto',
-                        maxWidth: '100%',
-                        transform: 'rotate(-1.55deg)',
-                        transformOrigin: 'right bottom',
-                        opacity: 1,
-                        pointerEvents: 'none',
-                        zIndex: 0,
-                      }}
-                    />
-                  )}
-                  {activeShareIndex < shareImages.length - 1 && (
+                  <div
+                    style={{
+                      position: 'relative',
+                      width: '100%',
+                      display: 'flex',
+                      justifyContent: 'center',
+                      overflow: 'visible',
+                    }}
+                  >
+                    {activeShareIndex > 0 && (
+                      <img
+                        src={shareImages[activeShareIndex - 1]?.url}
+                        alt="Previous share preview"
+                        aria-hidden
+                        style={{
+                          position: 'absolute',
+                          height: 'auto',
+                          maxWidth: '100%',
+                          transform: 'rotate(-1.55deg)',
+                          transformOrigin: 'right bottom',
+                          opacity: 1,
+                          pointerEvents: 'none',
+                          zIndex: 0,
+                        }}
+                      />
+                    )}
+                    {activeShareIndex < shareImages.length - 1 && (
+                      <img
+                        src={shareImages[activeShareIndex + 1]?.url}
+                        alt="Next share preview"
+                        aria-hidden
+                        style={{
+                          position: 'absolute',
+                          height: 'auto',
+                          maxWidth: '100%',
+                          transform: 'rotate(1.55deg)',
+                          transformOrigin: 'left bottom',
+                          opacity: 1,
+                          pointerEvents: 'none',
+                          zIndex: 0,
+                        }}
+                      />
+                    )}
                     <img
-                      src={shareImages[activeShareIndex + 1]?.url}
-                      alt="Next share preview"
-                      aria-hidden
+                      src={shareImages[activeShareIndex]?.url}
+                      alt="Share preview"
                       style={{
-                        position: 'absolute',
+                        width: '100%',
                         height: 'auto',
-                        maxWidth: '100%',
-                        transform: 'rotate(1.55deg)',
-                        transformOrigin: 'left bottom',
-                        opacity: 1,
-                        pointerEvents: 'none',
-                        zIndex: 0,
+                        display: 'block',
+                        borderRadius: px2vmin(12),
+                        boxShadow: 'none',
+                        position: 'relative',
+                        zIndex: 1,
                       }}
                     />
-                  )}
-                  <img
-                    src={shareImages[activeShareIndex]?.url}
-                    alt="Share preview"
-                    style={{
-                      width: '100%',
-                      height: 'auto',
-                      display: 'block',
-                      borderRadius: px2vmin(12),
-                      boxShadow: 'none',
-                      position: 'relative',
-                      zIndex: 1,
-                    }}
-                  />
+                  </div>
                 </div>
-              </div>
-            )}
+              )}
+
+              {shareImages.length > 0 && (
+                <div
+                  style={{
+                    position: 'absolute',
+                    top: px2vmin(514),
+                    left: '50%',
+                    transform: 'translateX(-50%)',
+                    display: 'flex',
+                    alignItems: 'center',
+                    gap: px2vmin(8),
+                    pointerEvents: 'auto',
+                  }}
+                >
+                  {shareImages.map((item, index) => {
+                    const isActive = index === activeShareIndex;
+                    return (
+                      <button
+                        key={item.id}
+                        type="button"
+                        aria-label={`Select share image ${index + 1}`}
+                        onClick={() => setActiveShareIndex(index)}
+                        style={{
+                          width: isActive ? px2vmin(24) : px2vmin(6),
+                          height: px2vmin(6),
+                          borderRadius: isActive ? px2vmin(20) : px2vmin(6),
+                          background: isActive ? 'rgba(169, 132, 228, 1)' : 'rgba(232, 222, 248, 1)',
+                          border: 'none',
+                          padding: 0,
+                        }}
+                      />
+                    );
+                  })}
+                </div>
+              )}
+            </div>
+          </div>
 
-            {shareImages.length > 0 && (
+          {/* 底部弹窗 */}
+          {shareImages.length > 0 && (
+            <div
+              style={{
+                position: 'fixed',
+                bottom: 0,
+                left: '50%',
+                transform: 'translateX(-50%)',
+                width: px2vw(375),
+                height: px2vmin(162),
+                backgroundColor: '#34313D',
+                borderTopLeftRadius: px2vmin(32),
+                borderTopRightRadius: px2vmin(32),
+                zIndex: 9999,
+                display: 'flex',
+                flexDirection: 'column',
+                alignItems: 'center',
+                paddingTop: px2vmin(12),
+                paddingBottom: px2vmin(24),
+              }}
+            >
+              {/* 顶部拖拽指示条 */}
               <div
                 style={{
-                  position: 'absolute',
-                  top: px2vmin(632),
-                  left: '50%',
-                  transform: 'translateX(-50%)',
-                  display: 'flex',
-                  alignItems: 'center',
-                  gap: px2vmin(8),
+                  width: px2vmin(32),
+                  height: px2vmin(4),
+                  backgroundColor: '#79747E',
+                  borderRadius: px2vmin(4),
                 }}
-              >
-                {shareImages.map((item, index) => {
-                  const isActive = index === activeShareIndex;
-                  return (
-                    <button
-                      key={item.id}
-                      type="button"
-                      aria-label={`Select share image ${index + 1}`}
-                      onClick={() => setActiveShareIndex(index)}
-                      style={{
-                        width: isActive ? px2vmin(24) : px2vmin(6),
-                        height: px2vmin(6),
-                        borderRadius: isActive ? px2vmin(20) : px2vmin(6),
-                        background: isActive ? 'rgba(169, 132, 228, 1)' : 'rgba(232, 222, 248, 1)',
-                        border: 'none',
-                        padding: 0,
-                      }}
-                    />
-                  );
-                })}
-              </div>
-            )}
+              />
 
-            {/* 保存按钮 */}
-            {shareImages.length > 0 && (
+              {/* 提示文字和按钮 */}
               <div
                 style={{
-                  position: 'absolute',
-                  left: '50%',
-                  top: px2vmin(670),
-                  transform: 'translateX(-50%)',
-                  width: px2vmin(211),
+                  marginTop: px2vmin(24),
                   display: 'flex',
                   flexDirection: 'column',
                   alignItems: 'center',
+                  width: '100%',
                 }}
               >
+                {/* 提示文字 */}
+                <div
+                  style={{
+                    color: '#FFFFFF',
+                    fontSize: px2vmin(14),
+                    textAlign: 'center',
+                    lineHeight: px2vmin(14),
+                    marginBottom: px2vmin(12),
+                  }}
+                >
+                  长按上方图片保存到相册后领取奖励
+                </div>
+
+                {/* 领取奖励按钮 */}
                 <button
                   type="button"
                   onClick={handleSaveImage}
@@ -475,105 +517,24 @@ export const FirstLoginRewardPage = () => {
                     border: 'none',
                     padding: 0,
                     background: 'none',
-                    width: '100%',
+                    // width: px2vmin(118),
+                    height: px2vmin(48),
                     cursor: 'pointer',
                   }}
                 >
                   <img
-                    src={saveBtnImage}
-                    alt="Save"
+                    src={claimGiftBtnImage}
+                    alt="Claim Gift"
                     style={{
-                      width: '100%',
+                      height: '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>
-            )}
-
-              {/* 底部分享按钮区域 - 已注释 */}
-              {/* <div
-                style={{
-                  position: 'fixed',
-                  bottom: 0,
-                  left: '50%',
-                  transform: 'translateX(-50%)',
-                  width: px2vw(375),
-                  height: px2vmin(158),
-                  backgroundColor: '#34313D',
-                  borderTopLeftRadius: px2vmin(32),
-                  borderTopRightRadius: px2vmin(32),
-                  display: 'flex',
-                  alignItems: 'center',
-                  justifyContent: 'center',
-                }}
-              >
-                <div
-                  style={{
-                    display: 'flex',
-                    alignItems: 'center',
-                    justifyContent: 'center',
-                    gap: px2vmin(24),
-                  }}
-                >
-                  {[
-                    { icon: wechatIcon, label: 'WeChat' },
-                    { icon: qqIcon, label: 'QQ' },
-                    { icon: saveIcon, label: 'Save' },
-                  ].map(({ icon, label }) => (
-                    <div
-                      key={label}
-                      style={{
-                        display: 'flex',
-                        flexDirection: 'column',
-                        alignItems: 'center',
-                        justifyContent: 'center',
-                        gap: px2vmin(8),
-                      }}
-                    >
-                      <img
-                        src={icon}
-                        alt={label}
-                        style={{
-                          width: px2vmin(56),
-                          height: px2vmin(56),
-                        }}
-                      />
-                      <div
-                        style={{
-                          width: px2vmin(56),
-                          height: px2vmin(16),
-                          fontSize: px2vmin(12),
-                          lineHeight: `${px2vmin(16)}`,
-                          color: '#FFFFFF',
-                          textAlign: 'center',
-                        }}
-                      >
-                        {label}
-                      </div>
-                    </div>
-                  ))}
-                </div>
-              </div> */}
             </div>
-          </div>
-        </div>
+          )}
+        </>
       )}
     </div>
   );

+ 299 - 35
frontend/src/pages/ProfilePage.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useState, useCallback, useRef } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { px2vmin, px2vmax, px2vw } from '@/utils/viewport';
-import { getUserInfo, updateUsername, updateBotName } from '@/utils/api';
+import { getUserInfo, updateUsername, updateBotName, getUserUIState, updateUserUIState, updateBackgroundImageId } from '@/utils/api';
 
 // 获取后端 API 基础 URL(与 api.ts 中的逻辑保持一致)
 const getApiBaseUrl = (): string => {
@@ -21,7 +21,7 @@ const getApiBaseUrl = (): string => {
   
   return apiUrl;
 };
-import { getUserId, getStorage, setStorage, STORAGE_KEYS } from '@/utils/storage';
+import { getUserId, getStorage, setStorage, STORAGE_KEYS, setCurrentAvatarId as setAvatarIdToStorage, setCurrentBackgroundImageId, syncBackgroundImageToStorage } from '@/utils/storage';
 import { getOssUrl } from '@/utils/oss';
 import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
 
@@ -46,6 +46,8 @@ export const ProfilePage = () => {
   const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
   const [hasCap, setHasCap] = useState<boolean>(false);
   const [hasBackgroundReward, setHasBackgroundReward] = useState<boolean>(false);
+  const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(null);
+  const [currentBackgroundImageId, setCurrentBackgroundImageId] = useState<string | null>(null);
   const [loading, setLoading] = useState<boolean>(true);
   const [userLevel, setUserLevel] = useState<number>(0);
   const [totalSpeechDurationMs, setTotalSpeechDurationMs] = useState<number>(0);
@@ -59,10 +61,15 @@ export const ProfilePage = () => {
   const botNameInputRef = useRef<HTMLInputElement>(null);
   const [showEnergyStoneModal, setShowEnergyStoneModal] = useState<boolean>(false);
   const [energyStoneModalType, setEnergyStoneModalType] = useState<'mission' | 'conversation'>('mission');
+  const [showFeedbackModal, setShowFeedbackModal] = useState<boolean>(false);
+  const [feedbackText, setFeedbackText] = useState<string>('');
+  const [isSubmittingFeedback, setIsSubmittingFeedback] = useState<boolean>(false);
   
   const cancelBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/cancel.png';
   const changeBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/change.png';
   const changeBtnActiveImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/change-a.png';
+  const sendBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/send.png';
+  const sendBtnActiveImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/send-a.png';
   const focusGradient = 'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
   // 获取能量石数量
@@ -180,6 +187,12 @@ export const ProfilePage = () => {
           setTotalSpeechDurationMs(userInfo.data.total_speech_duration_ms || 0);
         }
 
+        // 获取用户 UI 状态(包含 current_avatar_id)
+        const uiState = await getUserUIState(userId);
+        if (uiState.success && uiState.data) {
+          setCurrentAvatarId(uiState.data.current_avatar_id || null);
+        }
+
         // 获取能量石数量
         await fetchEnergyStoneCounts(userId);
 
@@ -200,7 +213,7 @@ export const ProfilePage = () => {
 
   // 防止弹窗打开时背景滚动
   useEffect(() => {
-    if (showUsernameModal || showBotNameModal || showEnergyStoneModal) {
+    if (showUsernameModal || showBotNameModal || showEnergyStoneModal || showFeedbackModal) {
       document.body.style.overflow = 'hidden';
     } else {
       document.body.style.overflow = '';
@@ -209,23 +222,62 @@ export const ProfilePage = () => {
     return () => {
       document.body.style.overflow = '';
     };
-  }, [showUsernameModal, showBotNameModal, showEnergyStoneModal]);
+  }, [showUsernameModal, showBotNameModal, showEnergyStoneModal, showFeedbackModal]);
 
   const handleClose = useCallback(() => {
     navigate(-1);
   }, [navigate]);
 
-  const handleHeadClick = useCallback(() => {
+  const handleHeadClick = useCallback(async () => {
+    const userId = getUserId();
+    if (!userId) return;
+
     if (!hasCap) {
       navigate('/first-login');
+      return;
     }
-  }, [hasCap, navigate]);
 
-  const handleBackgroundClick = useCallback(() => {
+    // 已拥有情况下,切换头像
+    const newAvatarId = currentAvatarId === 'avatar_registered_cap' 
+      ? 'avatar_default' 
+      : 'avatar_registered_cap';
+    
+    try {
+      const result = await updateUserUIState(userId, { current_avatar_id: newAvatarId });
+      if (result.success) {
+        setCurrentAvatarId(newAvatarId); // 更新 React state
+        setAvatarIdToStorage(newAvatarId); // 同步到 localStorage
+      }
+    } catch (error) {
+      console.error('切换头像失败:', error);
+    }
+  }, [hasCap, currentAvatarId, navigate]);
+
+  const handleBackgroundClick = useCallback(async () => {
+    const userId = getUserId();
+    if (!userId) return;
+
     if (!hasBackgroundReward) {
       navigate('/first-login-reward');
+      return;
     }
-  }, [hasBackgroundReward, navigate]);
+
+    // 已拥有情况下,切换背景图片
+    const newBackgroundImageId = currentBackgroundImageId === 'happynewyear' 
+      ? 'default' 
+      : 'happynewyear';
+    
+    try {
+      const result = await updateBackgroundImageId(userId, newBackgroundImageId);
+      if (result.success) {
+        setCurrentBackgroundImageId(newBackgroundImageId); // 更新 React state
+        // 同步到 localStorage(包括 URL 和透明度)
+        syncBackgroundImageToStorage(newBackgroundImageId, null);
+      }
+    } catch (error) {
+      console.error('切换背景图片失败:', error);
+    }
+  }, [hasBackgroundReward, currentBackgroundImageId, navigate]);
 
   // 打开编辑用户名弹窗
   const handleEditUsername = useCallback(() => {
@@ -316,34 +368,65 @@ export const ProfilePage = () => {
     setShowEnergyStoneModal(false);
   }, []);
 
+  // 打开反馈弹窗
+  const handleOpenFeedbackModal = useCallback(() => {
+    setShowFeedbackModal(true);
+    setFeedbackText('');
+  }, []);
+
+  // 关闭反馈弹窗
+  const handleCloseFeedbackModal = useCallback(() => {
+    setShowFeedbackModal(false);
+    setFeedbackText('');
+  }, []);
+
+  // 提交反馈
+  const handleSubmitFeedback = useCallback(async () => {
+    const userId = getUserId();
+    if (!userId || !feedbackText.trim()) {
+      return;
+    }
+
+    setIsSubmittingFeedback(true);
+    try {
+      const apiBaseUrl = getApiBaseUrl();
+      const response = await fetch(`${apiBaseUrl}/api/users/${userId}/feedback`, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({ content: feedbackText.trim() }),
+      });
+
+      if (response.ok) {
+        setShowFeedbackModal(false);
+        setFeedbackText('');
+        // 可以添加成功提示
+      } else {
+        console.error('提交反馈失败:', response.statusText);
+      }
+    } catch (error) {
+      console.error('提交反馈异常:', error);
+    } finally {
+      setIsSubmittingFeedback(false);
+    }
+  }, [feedbackText]);
+
   // 计算升级所需时间和当前进度
   const getLevelProgress = useCallback(() => {
     const level = userLevel;
     const totalSeconds = Math.floor(totalSpeechDurationMs / 1000);
 
-    // 定义各等级所需的累计时间(秒)
-    const levelThresholds = [0, 10, 30, 60, 120, 240];
+    // 定义各等级升级需要的时长(秒)
+    const requiredTimesByLevel = [10, 30, 60, 120, 240, 240];
     
-    // 如果已经是最高等级
-    if (level >= 5) {
-      const displaySeconds = Math.max(0, Math.floor(totalSeconds - 120));
-      return {
-        currentTime: 120,
-        requiredTime: 120,
-        progress: 100,
-        displayText: `${displaySeconds}/120`,
-      };
-    }
-
-    // 当前等级所需的累计时间
-    const currentLevelThreshold = levelThresholds[level];
-    // 下一等级所需的累计时间
-    const nextLevelThreshold = levelThresholds[level + 1];
-    // 升级所需的时间
-    const requiredTime = nextLevelThreshold - currentLevelThreshold;
-    // 当前等级内的已用时间
-    const currentTime = Math.max(0, totalSeconds - currentLevelThreshold);
-    // 进度百分比
+    // 获取当前等级升级需要的时长
+    const requiredTime = requiredTimesByLevel[level] || 240;
+    
+    // 当前已沟通的时长(秒)= 总时长
+    const currentTime = Math.max(0, totalSeconds);
+    
+    // 进度百分比 = 当前已沟通时长 / 升级需要的时长
     const progress = Math.min(100, (currentTime / requiredTime) * 100);
 
     return {
@@ -386,6 +469,9 @@ export const ProfilePage = () => {
         .profile-modal-input::placeholder {
           color: rgba(245, 243, 240, 0.45);
         }
+        .feedback-textarea::placeholder {
+          color: rgba(245, 243, 240, 0.45);
+        }
       `}</style>
     <div
       style={{
@@ -684,7 +770,7 @@ export const ProfilePage = () => {
       >
         {/* Lumi 头像 */}
         <img
-          src={hasCap ? lumiCap : lumiNoCap}
+          src={currentAvatarId === 'avatar_registered_cap' ? lumiCap : lumiNoCap}
           alt="Lumi"
           style={{
             width: px2vmin(180),
@@ -767,7 +853,11 @@ export const ProfilePage = () => {
                 style={{
                   width: px2vmin(108),
                   height: px2vmin(70),
-                  border: `2px solid ${hasCap ? '#BF9DF8' : 'rgba(245,243,240, 0.3)'}`,
+                  border: `2px solid ${
+                    hasCap && currentAvatarId === 'avatar_registered_cap' 
+                      ? '#BF9DF8' 
+                      : 'rgba(245,243,240, 0.3)'
+                  }`,
                   borderRadius: px2vmin(12),
                   marginLeft: '50%',
                   transform: 'translateX(-50%)',
@@ -775,7 +865,7 @@ export const ProfilePage = () => {
                   display: 'flex',
                   alignItems: 'center',
                   justifyContent: 'center',
-                  cursor: hasCap ? 'default' : 'pointer',
+                  cursor: hasCap ? 'pointer' : 'pointer',
                 }}
               >
                 <img
@@ -828,13 +918,17 @@ export const ProfilePage = () => {
                 style={{
                   width: px2vmin(108),
                   height: px2vmin(70),
-                  border: `2px solid ${hasBackgroundReward ? '#BF9DF8' : 'rgba(245,243,240, 0.3)'}`,
+                  border: `2px solid ${
+                    hasBackgroundReward && currentBackgroundImageId === 'happynewyear' 
+                      ? '#BF9DF8' 
+                      : 'rgba(245,243,240, 0.3)'
+                  }`,
                   borderRadius: px2vmin(12),
                   marginLeft: '50%',
                   transform: 'translateX(-50%)',
                   position: 'relative',
                   overflow: 'hidden',
-                  cursor: hasBackgroundReward ? 'default' : 'pointer',
+                  cursor: 'pointer',
                 }}
               >
                 <img
@@ -846,6 +940,34 @@ export const ProfilePage = () => {
                     objectFit: 'cover',
                   }}
                 />
+                {!hasBackgroundReward && (
+                  <>
+                    {/* 蒙层 */}
+                    <div
+                      style={{
+                        position: 'absolute',
+                        top: 0,
+                        left: 0,
+                        right: 0,
+                        bottom: 0,
+                        backgroundColor: 'rgba(245, 243, 240, 0.3)',
+                        display: 'flex',
+                        alignItems: 'center',
+                        justifyContent: 'center',
+                      }}
+                    >
+                      {/* 礼物图标 */}
+                      <img
+                        src="https://lyrria-pro.oss-cn-beijing.aliyuncs.com/gift.png"
+                        alt="Gift"
+                        style={{
+                          width: px2vmin(40),
+                          height: px2vmin(40),
+                        }}
+                      />
+                    </div>
+                  </>
+                )}
               </div>
               {/* Background 文字 */}
               <div
@@ -877,6 +999,7 @@ export const ProfilePage = () => {
       >
         {/* 意见反馈 & 帮助 */}
         <div
+          onClick={handleOpenFeedbackModal}
           style={{
             display: 'flex',
             alignItems: 'center',
@@ -885,6 +1008,7 @@ export const ProfilePage = () => {
             borderTop: '1px solid #34313D',
             marginLeft: px2vmin(10),
             marginRight: px2vmin(10),
+            cursor: 'pointer',
           }}
         >
           <span
@@ -1385,6 +1509,146 @@ export const ProfilePage = () => {
         onClose={handleCloseEnergyStoneModal}
         stoneType={energyStoneModalType}
       />
+
+      {/* 意见反馈弹窗 */}
+      {showFeedbackModal && (
+        <>
+          {/* 遮罩层 */}
+          <div
+            onClick={handleCloseFeedbackModal}
+            style={{
+              position: 'fixed',
+              top: 0,
+              left: 0,
+              right: 0,
+              bottom: 0,
+              backgroundColor: 'rgba(0, 0, 0, 0.5)',
+              zIndex: 9998,
+              animation: 'fadeIn 0.3s ease-out',
+            }}
+          />
+
+          {/* 弹窗内容 */}
+          <div
+            style={{
+              position: 'fixed',
+              bottom: 0,
+              left: '50%',
+              transform: 'translateX(-50%)',
+              width: px2vw(375),
+              height: px2vmin(420),
+              backgroundColor: '#34313D',
+              borderTopLeftRadius: px2vmin(32),
+              borderTopRightRadius: px2vmin(32),
+              zIndex: 9999,
+              display: 'flex',
+              flexDirection: 'column',
+              alignItems: 'center',
+              paddingTop: px2vmin(12),
+              paddingBottom: px2vmin(32),
+              paddingLeft: px2vmin(32),
+              paddingRight: px2vmin(32),
+            }}
+          >
+            {/* 顶部拖拽指示条 */}
+            <div
+              style={{
+                width: px2vmin(32),
+                height: px2vmin(4),
+                backgroundColor: '#79747E',
+                borderRadius: px2vmin(4),
+              }}
+            />
+
+            {/* 文字标签 */}
+            <div
+              style={{
+                width: '100%',
+                fontSize: px2vmin(14),
+                color: '#fff',
+                fontWeight: 'bold',
+                marginTop: px2vmin(24),
+                marginBottom: px2vmin(16),
+                textAlign: 'left',
+              }}
+            >
+              意见反馈 & 帮助
+            </div>
+
+            {/* 输入区域 */}
+            <div
+              style={{
+                width: px2vmin(311),
+                height: px2vmin(224),
+                marginBottom: px2vmin(32),
+              }}
+            >
+              <textarea
+                value={feedbackText}
+                onChange={(e) => setFeedbackText(e.target.value)}
+                placeholder="在此输入您的反馈或遇到的问题。为了保护您的隐私,请不要包含任何个人信息,例如您的姓名和地址"
+                className="feedback-textarea"
+                style={{
+                  width: '100%',
+                  height: '100%',
+                  backgroundColor: 'rgba(245, 243, 240, 0.1)',
+                  border: 'none',
+                  borderRadius: px2vmin(12),
+                  padding: px2vmin(16),
+                  fontSize: px2vmin(14),
+                  color: 'rgba(245, 243, 240, 0.7)',
+                  fontFamily: 'inherit',
+                  resize: 'none',
+                  outline: 'none',
+                }}
+              />
+            </div>
+
+            {/* 按钮组 */}
+            <div
+              style={{
+                display: 'flex',
+                gap: px2vmin(43),
+                justifyContent: 'center',
+                width: '100%',
+              }}
+            >
+              <button
+                onClick={handleCloseFeedbackModal}
+                disabled={isSubmittingFeedback}
+                style={{
+                  width: px2vmin(90),
+                  height: px2vmin(46),
+                  backgroundImage: `url(${cancelBtnImage})`,
+                  backgroundSize: 'cover',
+                  backgroundRepeat: 'no-repeat',
+                  backgroundPosition: 'center',
+                  border: 'none',
+                  cursor: isSubmittingFeedback ? 'default' : 'pointer',
+                  padding: 0,
+                  opacity: isSubmittingFeedback ? 0.6 : 1,
+                }}
+              />
+              <button
+                onClick={handleSubmitFeedback}
+                disabled={!feedbackText.trim() || isSubmittingFeedback}
+                style={{
+                  width: px2vmin(76),
+                  height: px2vmin(46),
+                  backgroundImage: `url(${feedbackText.trim() ? sendBtnActiveImage : sendBtnImage})`,
+                  backgroundSize: 'cover',
+                  backgroundRepeat: 'no-repeat',
+                  backgroundPosition: 'center',
+                  border: 'none',
+                  cursor: feedbackText.trim() && !isSubmittingFeedback ? 'pointer' : 'default',
+                  padding: 0,
+                  opacity: feedbackText.trim() && !isSubmittingFeedback ? 1 : 0.6,
+                }}
+              />
+            </div>
+          </div>
+        </>
+      )}
     </div>
     </>
   );

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

@@ -119,7 +119,7 @@ export const ShareSuccessPage = () => {
           border: 'none',
           padding: 0,
           background: 'none',
-          width: px2vmin(163),
+          width: px2vmin(279),
         }}
       >
         <img

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

@@ -176,6 +176,34 @@ export const updateUsername = async (user_id: string, username: string): Promise
   return response.json();
 };
 
+/**
+ * 更新背景图片ID
+ */
+export interface UpdateBackgroundImageIdResponse {
+  success: boolean;
+  message: string;
+}
+
+export const updateBackgroundImageId = async (
+  user_id: string,
+  background_image_id: string,
+): Promise<UpdateBackgroundImageIdResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/background-image-id`, {
+    method: 'PUT',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    body: JSON.stringify({ background_image_id }),
+  });
+
+  if (!response.ok) {
+    throw new Error(`更新背景图片ID失败: ${response.statusText}`);
+  }
+
+  return response.json();
+};
+
 /**
  * 更新机器人名字
  */

+ 26 - 5
frontend/src/utils/storage.ts

@@ -339,10 +339,10 @@ export const getBackgroundImageUrlById = (image_id: string | null | undefined):
 export const getCurrentBackgroundImageOpacity = (): number => {
   const value = getStorage(STORAGE_KEYS.CURRENT_BACKGROUND_IMAGE_OPACITY);
   if (value === null || value === undefined) {
-    return 0.5; // 默认透明度
+    return 1; // 默认透明度
   }
   const parsed = parseFloat(value);
-  return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0.5;
+  return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 1;
 };
 
 /**
@@ -381,7 +381,7 @@ export const syncBackgroundImageToStorage = (
       setBackgroundImage(defaultId, defaultUrl);
     }
     // 设置默认透明度
-    setCurrentBackgroundImageOpacity(0.5);
+    setCurrentBackgroundImageOpacity(1);
     return;
   }
   
@@ -400,14 +400,35 @@ export const syncBackgroundImageToStorage = (
       setCurrentBackgroundImageOpacity(metadata.opacity);
     } else {
       // 如果没有配置透明度,使用默认值
-      setCurrentBackgroundImageOpacity(0.5);
+      setCurrentBackgroundImageOpacity(1);
     }
   } else {
     // 如果没有metadata,使用默认透明度
-    setCurrentBackgroundImageOpacity(0.5);
+    setCurrentBackgroundImageOpacity(1);
   }
 };
 
+/**
+ * 获取当前形象ID
+ */
+export const getCurrentAvatarId = (): string | null => {
+  return getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
+};
+
+/**
+ * 设置当前形象ID
+ */
+export const setCurrentAvatarId = (avatar_id: string): boolean => {
+  return setStorage(STORAGE_KEYS.CURRENT_AVATAR_ID, avatar_id);
+};
+
+/**
+ * 清除当前形象ID
+ */
+export const clearCurrentAvatarId = (): boolean => {
+  return removeStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
+};
+
 /**
  * 清除用户相关的路由和页面状态
  * 当用户ID不存在时调用,清空 Lyrria_visited_routes 和 Lyrria_current_page

+ 226 - 0
项目文档/Profile页面Lumi图片显示规则说明.md

@@ -0,0 +1,226 @@
+# Profile 页面 Lumi 图片显示规则说明
+
+## 元素位置
+
+**文件**: `frontend/src/pages/ProfilePage.tsx` (第 763-773 行)
+
+**代码实现**:
+```typescript
+<img
+  src={hasCap ? lumiCap : lumiNoCap}
+  alt="Lumi"
+  style={{
+    width: px2vmin(180),
+    height: px2vmin(180),
+    position: 'absolute',
+    left: 0,
+    top: px2vmax(-56),
+  }}
+/>
+```
+
+---
+
+## 显示规则详解
+
+### 1. 尺寸计算(width & height)
+
+#### 代码中的设置
+- `width: px2vmin(180)`
+- `height: px2vmin(180)`
+
+#### 实际渲染的 CSS 值
+```css
+width: min(
+  calc(180 * min(100vw, 600PX) / 375),
+  calc(22.167487684729064 * var(--vh, 1vh))
+);
+height: min(
+  calc(180 * min(100vw, 600PX) / 375),
+  calc(22.167487684729064 * var(--vh, 1vh))
+);
+```
+
+#### 计算逻辑
+
+| 计算项 | 公式 | 说明 |
+|--------|------|------|
+| **基于宽度的值** | `calc(180 * min(100vw, 600PX) / 375)` | 按视口宽度等比缩放,最大不超过 600px 视口 |
+| **基于高度的值** | `calc(22.167487684729064 * var(--vh, 1vh))` | 按视口高度等比缩放(180/812*100 ≈ 22.17) |
+| **最终尺寸** | `min(宽度值, 高度值)` | 取两者较小值,确保图片不会过度放大 |
+
+#### 设计稿基准
+- **设计稿尺寸**: 375px × 812px
+- **图片原始尺寸**: 180px × 180px
+- **高度比例**: 180 ÷ 812 × 100 = 22.167487684729064%
+
+---
+
+### 2. 位置计算(top)
+
+#### 代码中的设置
+- `top: px2vmax(-56)`
+
+#### 实际渲染的 CSS 值
+```css
+top: max(
+  calc(-56 * min(100vw, 600PX) / 375),
+  calc(-6.896551724137931 * var(--vh, 1vh))
+);
+```
+
+#### 计算逻辑
+
+| 计算项 | 公式 | 说明 |
+|--------|------|------|
+| **基于宽度的偏移** | `calc(-56 * min(100vw, 600PX) / 375)` | 按视口宽度等比缩放负偏移 |
+| **基于高度的偏移** | `calc(-6.896551724137931 * var(--vh, 1vh))` | 按视口高度等比缩放负偏移(-56/812*100 ≈ -6.90) |
+| **最终位置** | `max(宽度偏移, 高度偏移)` | 取两者较大值(负值中较大 = 更接近 0),确保图片向上偏移足够 |
+
+#### 设计稿基准
+- **原始偏移**: -56px(向上偏移,使图片部分超出父容器)
+- **高度比例**: -56 ÷ 812 × 100 = -6.896551724137931%
+
+---
+
+### 3. 水平位置(left)
+
+#### 代码中的设置
+- `left: 0`
+
+#### 说明
+- 图片左边缘对齐父容器(区域A)的左边缘
+- 无动态计算,固定为 0
+
+---
+
+## 适配策略总结
+
+### 核心函数
+
+| 函数 | 作用 | 使用场景 |
+|------|------|----------|
+| `px2vmin()` | 取宽度和高度计算的最小值 | 用于尺寸属性,防止过度放大 |
+| `px2vmax()` | 取宽度和高度计算的最大值 | 用于位置属性(负值),确保偏移足够 |
+
+### 适配特点
+
+1. **响应式缩放**
+   - 宽度方向:按视口宽度等比缩放,最大封顶 600px 视口
+   - 高度方向:按视口高度等比缩放,使用 `var(--vh)` 避免移动端地址栏影响
+
+2. **防止过度放大**
+   - 尺寸使用 `min()`,确保在宽屏或高屏设备上不会过度放大
+   - 保持图片比例和视觉一致性
+
+3. **位置自适应**
+   - 使用 `max()` 处理负偏移,确保在不同屏幕比例下都能正确向上偏移
+   - 保证图片部分超出父容器的视觉效果
+
+---
+
+## 实际渲染示例
+
+### 场景 1: iPhone X (375px × 812px)
+- `100vw = 375px`,`var(--vh) ≈ 8.12px`
+- **宽度**: `min(180px, 180px) = 180px`
+- **高度**: `min(180px, 180px) = 180px`
+- **top**: `max(-56px, -56px) = -56px`
+
+### 场景 2: 宽屏设备 (600px × 800px)
+- `min(100vw, 600PX) = 600px`,`var(--vh) ≈ 8px`
+- **宽度**: `min(288px, 177.34px) = 177.34px`(受高度限制)
+- **高度**: `min(288px, 177.34px) = 177.34px`
+- **top**: `max(-89.6px, -55.17px) = -55.17px`(受高度限制)
+
+### 场景 3: 高屏设备 (375px × 1000px)
+- `100vw = 375px`,`var(--vh) ≈ 10px`
+- **宽度**: `min(180px, 221.67px) = 180px`(受宽度限制)
+- **高度**: `min(180px, 221.67px) = 180px`
+- **top**: `max(-56px, -68.97px) = -56px`(受宽度限制)
+
+---
+
+---
+
+## 图片切换规则
+
+### 切换逻辑
+
+图片显示由 `currentAvatarId` 状态控制,基于用户当前使用的头像ID:
+
+```typescript
+src={currentAvatarId === 'avatar_registered_cap' ? lumiCap : lumiNoCap}
+```
+
+| currentAvatarId 值 | 显示的图片 | 图片文件 |
+|-------------------|----------|---------|
+| `'avatar_registered_cap'` | 带帽子的 Lumi | `Lumi-cap.png` |
+| `'avatar_default'` 或其他值 | 不带帽子的 Lumi | `Lumi-no-cap.png` |
+| `null` | 不带帽子的 Lumi | `Lumi-no-cap.png` |
+
+### `currentAvatarId` 状态设置
+
+**初始化逻辑**(第 180-188 行):
+1. 页面加载时调用 `getUserUIState(userId)`
+2. 从后端获取用户的 UI 状态:`GET /api/users/{userId}/ui-state`
+3. 读取 `user_ui_states` 表中的 `current_avatar_id` 字段
+4. 设置到 `currentAvatarId` 状态
+
+**关键代码**:
+```typescript
+const uiState = await getUserUIState(userId);
+if (uiState.success && uiState.data) {
+  setCurrentAvatarId(uiState.data.current_avatar_id || null);
+}
+```
+
+### 图片切换时机
+
+1. **页面首次加载时**
+   - 从后端 `user_ui_states` 表读取 `current_avatar_id`
+   - 根据值显示对应的图片
+
+2. **用户切换头像时**(第 240-247 行)
+   - 点击 Head 区域切换头像
+   - 调用 `updateUserUIState` 更新后端的 `current_avatar_id`
+   - 成功后立即更新 `currentAvatarId` 状态
+   - 图片会立即切换显示
+
+**切换逻辑**:
+```typescript
+const newAvatarId = currentAvatarId === 'avatar_registered_cap' 
+  ? 'avatar_default' 
+  : 'avatar_registered_cap';
+
+const result = await updateUserUIState(userId, { current_avatar_id: newAvatarId });
+if (result.success) {
+  setCurrentAvatarId(newAvatarId);  // 立即更新状态,图片会切换
+}
+```
+
+### 重要说明
+
+1. **基于当前使用状态**
+   - 图片显示基于用户**当前使用的头像ID**(`current_avatar_id`)
+   - 直接反映用户在 `user_ui_states` 表中的设置
+   - 与用户是否拥有该头像无关,只关注当前使用状态
+
+2. **实时同步**
+   - 切换头像后,图片会立即更新
+   - 无需刷新页面,状态变化会触发 React 重新渲染
+
+3. **数据来源**
+   - 数据来自 `user_ui_states` 表的 `current_avatar_id` 字段
+   - 与 `users` 表的 `avatar_ids`(拥有列表)是独立的
+
+---
+
+## 设计意图
+
+1. **视觉一致性**: 在不同设备上保持图片相对大小一致
+2. **防止溢出**: 使用 `min()` 防止图片过大
+3. **位置稳定**: 使用 `max()` 确保负偏移足够,保持设计效果
+4. **移动端优化**: 使用 `var(--vh)` 处理移动端地址栏/工具栏的影响
+5. **拥有权展示**: 图片显示用户是否拥有注册奖励头像,而非当前使用状态
+