zlwu 11 месяцев назад
Родитель
Сommit
4a9c4306b3

+ 1 - 1
backend/.env.cy

@@ -18,7 +18,7 @@ WS_TIMEOUT=60
 
 # 能量石 / 缓存配置
 # 能量石有效期(秒)
-ENERGY_STONE_TTL_SECONDS=20
+ENERGY_STONE_TTL_SECONDS=10
 # 对话石刷新间隔(秒)
 CONVERSATION_STONE_INTERVAL_SECONDS=30
 # 任务类型缓存时长(秒)

+ 1 - 1
backend/.env.lyrria

@@ -18,7 +18,7 @@ WS_TIMEOUT=60
 
 # 能量石 / 缓存配置
 # 能量石有效期(秒)
-ENERGY_STONE_TTL_SECONDS=20
+ENERGY_STONE_TTL_SECONDS=10
 # 对话石刷新间隔(秒)
 CONVERSATION_STONE_INTERVAL_SECONDS=30
 # 任务类型缓存时长(秒)

+ 15 - 0
backend/main.py

@@ -36,6 +36,21 @@ logging.basicConfig(
     datefmt='%Y-%m-%d %H:%M:%S'
 )
 
+# 🔇 过滤第三方库的 DEBUG 日志(避免刷屏)
+# 这些库的 DEBUG 日志包含 HTTP/2 协议细节、HPACK 压缩等底层信息,对生产环境无意义
+third_party_loggers = [
+    'openai',
+    'openai._base_client',
+    'httpcore',
+    'httpcore.http2',
+    'httpx',
+    'hpack',
+    'hpack.hpack',
+    'hpack.table',
+]
+for logger_name in third_party_loggers:
+    logging.getLogger(logger_name).setLevel(logging.INFO)
+
 logger = logging.getLogger(__name__)
 
 

+ 69 - 0
backend/services/energy_stone_service.py

@@ -275,6 +275,75 @@ class EnergyStoneService:
             logger.warning("获取能量石数量失败: user=%s error=%s", user_id, exc)
             return {"mission_count": 0, "conversation_count": 0}
 
+    async def get_active_stones(self, user_id: str) -> List[Dict[str, Any]]:
+        """获取用户未过期的能量石(用于页面恢复)"""
+        if not user_id:
+            return []
+
+        try:
+            async with self.db.get_session() as session:
+                now = datetime.utcnow()
+                active_stones: List[Dict[str, Any]] = []
+
+                # 查询未过期的任务能量石
+                # 条件:clicked_at IS NULL 且 appear_at + ttl_seconds > now
+                mission_result = await session.execute(
+                    select(MissionEnergyStone).where(
+                        MissionEnergyStone.user_id == user_id,
+                        MissionEnergyStone.clicked_at.is_(None),
+                    )
+                )
+                for stone in mission_result.scalars().all():
+                    appear_at = stone.appear_at or stone.created_at
+                    if appear_at:
+                        expire_at = appear_at.timestamp() + self.ttl_seconds
+                        if expire_at > now.timestamp():
+                            # 计算剩余 TTL(秒)
+                            remaining_ttl = int(expire_at - now.timestamp())
+                            payload = self._to_spawn_payload(
+                                stone=stone,
+                                stone_type="mission",
+                                extra={
+                                    "appear_reason": stone.appear_reason,
+                                    "mission_type": stone.mission_type,
+                                },
+                            )
+                            # 更新 TTL 为剩余时间
+                            payload["ttl_seconds"] = remaining_ttl
+                            active_stones.append(payload)
+
+                # 查询未过期的对话能量石
+                conversation_result = await session.execute(
+                    select(ConversationEnergyStone).where(
+                        ConversationEnergyStone.user_id == user_id,
+                        ConversationEnergyStone.clicked_at.is_(None),
+                    )
+                )
+                for stone in conversation_result.scalars().all():
+                    appear_at = stone.appear_at or stone.created_at
+                    if appear_at:
+                        expire_at = appear_at.timestamp() + self.ttl_seconds
+                        if expire_at > now.timestamp():
+                            # 计算剩余 TTL(秒)
+                            remaining_ttl = int(expire_at - now.timestamp())
+                            payload = self._to_spawn_payload(
+                                stone=stone,
+                                stone_type="conversation",
+                                extra={
+                                    "threshold_index": stone.threshold_index,
+                                    "total_duration_ms": stone.total_duration_ms,
+                                    "duration_increment_ms": stone.duration_increment_ms,
+                                },
+                            )
+                            # 更新 TTL 为剩余时间
+                            payload["ttl_seconds"] = remaining_ttl
+                            active_stones.append(payload)
+
+                return active_stones
+        except Exception as exc:
+            logger.warning("获取未过期能量石失败: user=%s error=%s", user_id, exc)
+            return []
+
     async def _get_mission_type_config(
         self, session, mission_type_key: str
     ) -> Optional[MissionType]:

+ 21 - 0
backend/websocket_handler.py

@@ -115,6 +115,9 @@ class WebSocketHandler:
         # 初始化服务
         await self._init_services()
         
+        # 🔥 恢复未过期的能量石(页面切换后重新显示)
+        await self._restore_active_stones()
+        
         # 保存事件循环引用,供回调线程使用
         self.event_loop = asyncio.get_running_loop()
         
@@ -1047,6 +1050,24 @@ class WebSocketHandler:
         )
         await self.websocket.send_text(message.model_dump_json())
     
+    async def _restore_active_stones(self):
+        """恢复未过期的能量石(页面切换后重新显示)"""
+        if not self.energy_stone_service or not self.user_id:
+            return
+        
+        try:
+            active_stones = await self.energy_stone_service.get_active_stones(self.user_id)
+            if active_stones:
+                logger.info(
+                    f"[{self.user_id}] 🔄 恢复 {len(active_stones)} 个未过期的能量石"
+                )
+                for stone_payload in active_stones:
+                    await self.send_energy_stone_spawn(stone_payload)
+        except Exception as exc:
+            logger.warning(
+                f"[{self.user_id}] ⚠️ 恢复能量石失败: {exc}"
+            )
+    
     async def send_emotion(self, request_id: str, emotion: str):
         """发送情感标签"""
         message = EmotionMessage(

+ 25 - 22
frontend/src/pages/FirstLoginPage.tsx

@@ -14,6 +14,7 @@ const phoneLogin = getOssUrl('phone-login.png');
 const emailLogin = getOssUrl('email-login.png');
 const continueBtn = getOssUrl('continue-btn.png');
 const continueBtnA = getOssUrl('continue-btn-a.png');
+const XIconImage = getOssUrl('x-icon.png');
 const focusGradient =
   'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
@@ -327,22 +328,23 @@ export const FirstLoginPage = () => {
                   className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                   style={{
                     right: px2vmin(12),
-                    width: px2vmin(20),
-                    height: px2vmin(20),
+                    width: px2vmin(24),
+                    height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
                   onClick={() => setPhoneNumber('')}
                   aria-label="清除"
                 >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                  <img
+                    src={XIconImage}
+                    alt="清除"
+                    style={{
+                      width: px2vmin(24),
+                      height: px2vmin(24),
+                      objectFit: 'contain',
+                    }}
+                    draggable={false}
+                  />
                 </button>
               </div>
               <div
@@ -392,22 +394,23 @@ export const FirstLoginPage = () => {
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                 style={{
                   right: px2vmin(12),
-                  width: px2vmin(20),
-                  height: px2vmin(20),
+                  width: px2vmin(24),
+                  height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
                 onClick={() => setEmail('')}
                 aria-label="清除"
               >
-                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                    <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                    <path
-                      d="M8 8L16 16M16 8L8 16"
-                      stroke="rgba(191, 157, 248, 1)"
-                      strokeWidth="2.5"
-                      strokeLinecap="round"
-                    />
-                  </svg>
+                <img
+                  src={XIconImage}
+                  alt="清除"
+                  style={{
+                    width: px2vmin(24),
+                    height: px2vmin(24),
+                    objectFit: 'contain',
+                  }}
+                  draggable={false}
+                />
               </button>
             </div>
             <div

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

@@ -16,6 +16,7 @@ import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { getOssUrl } from '../utils/oss';
 
 const ConfirmBtnImage = getOssUrl('confirm-btn.png');
+const XIconImage = getOssUrl('x-icon.png');
 const focusGradient =
   'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
@@ -366,8 +367,8 @@ export const GreetingPage = () => {
               className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
               style={{
                 right: px2vw(12),
-                width: px2vw(20),
-                height: px2vh(20),
+                width: px2vmin(24),
+                height: px2vmin(24),
                 opacity: isFocused && userName ? 1 : 0.6,
               }}
               onClick={() => {
@@ -376,15 +377,16 @@ export const GreetingPage = () => {
               }}
               aria-label="清除"
             >
-                <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                  <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                  <path
-                    d="M8 8L16 16M16 8L8 16"
-                    stroke="rgba(191, 157, 248, 1)"
-                    strokeWidth="2.5"
-                    strokeLinecap="round"
-                  />
-                </svg>
+              <img
+                src={XIconImage}
+                alt="清除"
+                style={{
+                  width: px2vmin(24),
+                  height: px2vmin(24),
+                  objectFit: 'contain',
+                }}
+                draggable={false}
+              />
             </button>
           </div>
           <div

+ 25 - 22
frontend/src/pages/LoginPage.tsx

@@ -13,6 +13,7 @@ const emailLogin = getOssUrl('email-login.png');
 const continueBtn = getOssUrl('continue-btn.png');
 const continueBtnA = getOssUrl('continue-btn-a.png');
 const backButton = getOssUrl('backbutn.png');
+const XIconImage = getOssUrl('x-icon.png');
 
 interface LocationState {
   loginType?: 'phone' | 'qq' | 'email';
@@ -384,22 +385,23 @@ export const LoginPage = () => {
                   className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                   style={{
                     right: px2vmin(12),
-                    width: px2vmin(20),
-                    height: px2vmin(20),
+                    width: px2vmin(24),
+                    height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
                   onClick={() => setPhoneNumber('')}
                   aria-label="清除"
                 >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                  <img
+                    src={XIconImage}
+                    alt="清除"
+                    style={{
+                      width: px2vmin(24),
+                      height: px2vmin(24),
+                      objectFit: 'contain',
+                    }}
+                    draggable={false}
+                  />
                 </button>
               </div>
               <div
@@ -449,22 +451,23 @@ export const LoginPage = () => {
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                 style={{
                   right: px2vw(12),
-                  width: px2vw(20),
-                  height: px2vh(20),
+                  width: px2vmin(24),
+                  height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
                 onClick={() => setEmail('')}
                 aria-label="清除"
               >
-                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                    <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                    <path
-                      d="M8 8L16 16M16 8L8 16"
-                      stroke="rgba(191, 157, 248, 1)"
-                      strokeWidth="2.5"
-                      strokeLinecap="round"
-                    />
-                  </svg>
+                <img
+                  src={XIconImage}
+                  alt="清除"
+                  style={{
+                    width: px2vmin(24),
+                    height: px2vmin(24),
+                    objectFit: 'contain',
+                  }}
+                  draggable={false}
+                />
               </button>
             </div>
             <div

+ 25 - 22
frontend/src/pages/LoginVerificationCodePage.tsx

@@ -11,6 +11,7 @@ import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
 const sendCodeNo = getOssUrl('send-code-no.png');
 const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
+const XIconImage = getOssUrl('x-icon.png');
 const focusGradient =
   'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
@@ -349,22 +350,23 @@ export const LoginVerificationCodePage = () => {
                   className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                   style={{
                     right: px2vmin(12),
-                    width: px2vmin(20),
-                    height: px2vmin(20),
+                    width: px2vmin(24),
+                    height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
                   onClick={() => setPhoneNumber('')}
                   aria-label="清除"
                 >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                  <img
+                    src={XIconImage}
+                    alt="清除"
+                    style={{
+                      width: px2vmin(24),
+                      height: px2vmin(24),
+                      objectFit: 'contain',
+                    }}
+                    draggable={false}
+                  />
                 </button>
               </div>
               <div
@@ -414,22 +416,23 @@ export const LoginVerificationCodePage = () => {
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                 style={{
                   right: px2vw(12),
-                  width: px2vw(20),
-                  height: px2vh(20),
+                  width: px2vmin(24),
+                  height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
                 onClick={() => setEmail('')}
                 aria-label="清除"
               >
-                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                    <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                    <path
-                      d="M8 8L16 16M16 8L8 16"
-                      stroke="rgba(191, 157, 248, 1)"
-                      strokeWidth="2.5"
-                      strokeLinecap="round"
-                    />
-                  </svg>
+                <img
+                  src={XIconImage}
+                  alt="清除"
+                  style={{
+                    width: px2vmin(24),
+                    height: px2vmin(24),
+                    objectFit: 'contain',
+                  }}
+                  draggable={false}
+                />
               </button>
             </div>
             <div

+ 13 - 11
frontend/src/pages/LumiNamingPage.tsx

@@ -6,6 +6,7 @@ import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { getOssUrl } from '../utils/oss';
 
 const ConfirmBtnImage = getOssUrl('confirm-btn.png');
+const XIconImage = getOssUrl('x-icon.png');
 
 const DEFAULT_LUMI_NAME = 'Lumi';
 const focusGradient =
@@ -211,22 +212,23 @@ export const LumiNamingPage = () => {
               className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
               style={{
                 right: px2vw(12),
-                width: px2vw(20),
-                height: px2vh(20),
+                width: px2vmin(24),
+                height: px2vmin(24),
                 opacity: isFocused && lumiName ? 1 : 0.6,
               }}
               onClick={() => setLumiName('')}
               aria-label="清除"
             >
-                <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                  <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                  <path
-                    d="M8 8L16 16M16 8L8 16"
-                    stroke="rgba(191, 157, 248, 1)"
-                    strokeWidth="2.5"
-                    strokeLinecap="round"
-                  />
-                </svg>
+              <img
+                src={XIconImage}
+                alt="清除"
+                style={{
+                  width: px2vmin(24),
+                  height: px2vmin(24),
+                  objectFit: 'contain',
+                }}
+                draggable={false}
+              />
             </button>
           </div>
           <div

+ 25 - 22
frontend/src/pages/ProfilePage.tsx

@@ -52,6 +52,7 @@ const headCap = getOssUrl('head-cap.png');
 const bgHappyNewYear = getOssUrl('bg-happynewyear-s.png');
 const starBg = getOssUrl('star-bg.png');
 const rocketIcon = getOssUrl('Rocket.gif');
+const XIconImage = getOssUrl('x-icon.png');
 
 
 export const ProfilePage = () => {
@@ -1240,8 +1241,8 @@ export const ProfilePage = () => {
                       top: '50%',
                       right: px2vw(12),
                       transform: 'translateY(-50%)',
-                      width: px2vw(20),
-                      height: px2vw(20),
+                      width: px2vmin(24),
+                      height: px2vmin(24),
                       background: 'transparent',
                       border: 'none',
                       cursor: 'pointer',
@@ -1250,15 +1251,16 @@ export const ProfilePage = () => {
                       justifyContent: 'center',
                     }}
                   >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                    <img
+                      src={XIconImage}
+                      alt="清除"
+                      style={{
+                        width: px2vmin(24),
+                        height: px2vmin(24),
+                        objectFit: 'contain',
+                      }}
+                      draggable={false}
+                    />
                   </button>
                 )}
               </div>
@@ -1441,8 +1443,8 @@ export const ProfilePage = () => {
                       top: '50%',
                       right: px2vw(12),
                       transform: 'translateY(-50%)',
-                      width: px2vw(20),
-                      height: px2vw(20),
+                      width: px2vmin(24),
+                      height: px2vmin(24),
                       background: 'transparent',
                       border: 'none',
                       cursor: 'pointer',
@@ -1451,15 +1453,16 @@ export const ProfilePage = () => {
                       justifyContent: 'center',
                     }}
                   >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                    <img
+                      src={XIconImage}
+                      alt="清除"
+                      style={{
+                        width: px2vmin(24),
+                        height: px2vmin(24),
+                        objectFit: 'contain',
+                      }}
+                      draggable={false}
+                    />
                   </button>
                 )}
               </div>

+ 25 - 22
frontend/src/pages/VerificationCodePage.tsx

@@ -11,6 +11,7 @@ import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
 const sendCodeNo = getOssUrl('send-code-no.png');
 const sendCodeImage = getOssUrl('send-code.png');
 const backButton = getOssUrl('backbutn.png');
+const XIconImage = getOssUrl('x-icon.png');
 const focusGradient =
   'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
 
@@ -351,22 +352,23 @@ export const VerificationCodePage = () => {
                   className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                   style={{
                     right: px2vmin(12),
-                    width: px2vmin(20),
-                    height: px2vmin(20),
+                    width: px2vmin(24),
+                    height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
                   onClick={() => setPhoneNumber('')}
                   aria-label="清除"
                 >
-                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                      <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                      <path
-                        d="M8 8L16 16M16 8L8 16"
-                        stroke="rgba(191, 157, 248, 1)"
-                        strokeWidth="2.5"
-                        strokeLinecap="round"
-                      />
-                    </svg>
+                  <img
+                    src={XIconImage}
+                    alt="清除"
+                    style={{
+                      width: px2vmin(24),
+                      height: px2vmin(24),
+                      objectFit: 'contain',
+                    }}
+                    draggable={false}
+                  />
                 </button>
               </div>
               <div
@@ -416,22 +418,23 @@ export const VerificationCodePage = () => {
                 className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
                 style={{
                   right: px2vw(12),
-                  width: px2vw(20),
-                  height: px2vh(20),
+                  width: px2vmin(24),
+                  height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
                 onClick={() => setEmail('')}
                 aria-label="清除"
               >
-                  <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
-                    <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
-                    <path
-                      d="M8 8L16 16M16 8L8 16"
-                      stroke="rgba(191, 157, 248, 1)"
-                      strokeWidth="2.5"
-                      strokeLinecap="round"
-                    />
-                  </svg>
+                <img
+                  src={XIconImage}
+                  alt="清除"
+                  style={{
+                    width: px2vmin(24),
+                    height: px2vmin(24),
+                    objectFit: 'contain',
+                  }}
+                  draggable={false}
+                />
               </button>
             </div>
             <div