zlwu 7 bulan lalu
induk
melakukan
41259e4bfc

+ 1 - 0
backend/error_codes.py

@@ -19,6 +19,7 @@ USER_NOT_FOUND_OR_UPDATE_FAILED = "USER_NOT_FOUND_OR_UPDATE_FAILED"
 UPDATE_BG_IMAGE_FAILED = "UPDATE_BG_IMAGE_FAILED"
 UPDATE_BOT_NAME_FAILED = "UPDATE_BOT_NAME_FAILED"
 UPDATE_BOT_NAME_TIMEOUT = "UPDATE_BOT_NAME_TIMEOUT"
+UPDATE_PREFERRED_LANGUAGE_FAILED = "UPDATE_PREFERRED_LANGUAGE_FAILED"
 GET_ENERGY_STONES_FAILED = "GET_ENERGY_STONES_FAILED"
 GET_AVATARS_FAILED = "GET_AVATARS_FAILED"
 GET_BG_CONFIG_FAILED = "GET_BG_CONFIG_FAILED"

+ 43 - 1
backend/main.py

@@ -11,7 +11,7 @@ import uuid
 import random
 import base64
 from datetime import datetime, timedelta
-from typing import Optional, List, Dict, Any
+from typing import Optional, List, Dict, Any, Literal
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
 from fastapi.exceptions import RequestValidationError
 from fastapi.responses import RedirectResponse, PlainTextResponse, JSONResponse
@@ -45,6 +45,7 @@ from error_codes import (
     UPDATE_BG_IMAGE_FAILED,
     UPDATE_BOT_NAME_FAILED,
     UPDATE_BOT_NAME_TIMEOUT,
+    UPDATE_PREFERRED_LANGUAGE_FAILED,
     GET_ENERGY_STONES_FAILED,
     GET_AVATARS_FAILED,
     GET_BG_CONFIG_FAILED,
@@ -72,6 +73,7 @@ from error_codes import (
 )
 from services.wechat_jsapi_service import WechatJSAPIService
 from services.energy_stone_service import energy_stone_service
+from services.scenario_topic_service import scenario_topic_service
 from models_db import VerificationCode, UserAvatar, UserBackgroundImage, Feedback, BackgroundImage
 from session_store import SessionStore
 
@@ -408,6 +410,11 @@ class UpdateBotNameRequest(BaseModel):
     bot_name: str
 
 
+class UpdatePreferredLanguageRequest(BaseModel):
+    """更新用户界面语言偏好(zh/en)"""
+    preferred_language: Literal["zh", "en"]
+
+
 class UpdateBackgroundImageIdRequest(BaseModel):
     """更新背景图片ID请求"""
     background_image_id: str
@@ -785,6 +792,22 @@ async def get_energy_stone_counts(user_id: str, _: None = Depends(require_user_s
         raise HTTPException(status_code=500, detail={"code": GET_ENERGY_STONES_FAILED})
 
 
+@app.get("/api/users/{user_id}/journal-latest-topic")
+async def get_journal_latest_topic(user_id: str, _: None = Depends(require_user_session)):
+    """
+    个人页「日记本」左侧图:用户最近在 ChatBot 触发特殊情景话题的最新一条记录。
+    无记录时 data 为 null。
+    """
+    try:
+        if not scenario_topic_service.db:
+            await scenario_topic_service.set_db_manager(db_manager)
+        latest = await scenario_topic_service.get_latest_log_for_journal(user_id)
+        return {"success": True, "data": latest}
+    except Exception:
+        logger.exception("获取日记本最近情景话题失败")
+        raise HTTPException(status_code=500, detail={"code": SERVER_ERROR})
+
+
 def _get_tutorial_intro_text(step: int) -> str | None:
     """根据教程步骤返回对应的固定英文文案。"""
     mapping: Dict[int, str] = {
@@ -1264,6 +1287,25 @@ async def update_bot_name(user_id: str, request: UpdateBotNameRequest, _: None =
         raise HTTPException(status_code=500, detail={"code": UPDATE_BOT_NAME_FAILED})
 
 
+@app.put("/api/users/{user_id}/preferred-language")
+async def update_preferred_language(
+    user_id: str,
+    request: UpdatePreferredLanguageRequest,
+    _: None = Depends(require_user_session),
+):
+    """更新用户首选界面语言(写入 users.preferred_language)"""
+    try:
+        ok = await user_service.update_preferred_language(user_id, request.preferred_language)
+        if not ok:
+            raise HTTPException(status_code=500, detail={"code": UPDATE_PREFERRED_LANGUAGE_FAILED})
+        return {"success": True, "message": "语言偏好已更新"}
+    except HTTPException:
+        raise
+    except Exception:
+        logger.exception("更新首选语言失败")
+        raise HTTPException(status_code=500, detail={"code": UPDATE_PREFERRED_LANGUAGE_FAILED})
+
+
 class SubmitFeedbackRequest(BaseModel):
     """提交反馈请求"""
     content: str

+ 37 - 2
backend/services/scenario_topic_service.py

@@ -2,9 +2,9 @@
 情景话题对话记录服务:查询已完成话题、写入对话记录
 """
 import logging
-from typing import Dict, List
+from typing import Dict, List, Optional, TypedDict
 
-from sqlalchemy import select, func
+from sqlalchemy import select, func, desc
 from sqlalchemy.exc import IntegrityError
 
 from database import db_manager
@@ -13,6 +13,12 @@ from models_db import UserSpecialScenarioTopicLog
 logger = logging.getLogger(__name__)
 
 
+class JournalLatestTopicPayload(TypedDict):
+    scenario_key: str
+    topic_key: str
+    conversed_at: str
+
+
 class ScenarioTopicService:
     """用户情景话题对话记录"""
 
@@ -59,6 +65,35 @@ class ScenarioTopicService:
             logger.warning(f"[ScenarioTopic] get_done_topic_keys failed: user_id={user_id}, scenario={scenario_key}, e={e}")
             return []
 
+    async def get_latest_log_for_journal(self, user_id: str) -> Optional[JournalLatestTopicPayload]:
+        """按 conversed_at 取该用户最近一条特殊情景话题记录(个人页日记本左侧图)。"""
+        if not user_id:
+            return None
+        try:
+            async with self.db.get_session() as session:
+                result = await session.execute(
+                    select(UserSpecialScenarioTopicLog)
+                    .where(UserSpecialScenarioTopicLog.user_id == user_id)
+                    .order_by(
+                        desc(UserSpecialScenarioTopicLog.conversed_at),
+                        desc(UserSpecialScenarioTopicLog.id),
+                    )
+                    .limit(1)
+                )
+                row = result.scalar_one_or_none()
+            if row is None:
+                return None
+            ca = row.conversed_at
+            conversed_at_iso = ca.isoformat() if ca else ""
+            return {
+                "scenario_key": row.scenario_key,
+                "topic_key": row.topic_key,
+                "conversed_at": conversed_at_iso,
+            }
+        except Exception as e:
+            logger.warning(f"[ScenarioTopic] get_latest_log_for_journal failed: user_id={user_id}, e={e}")
+            return None
+
     async def record_conversation(self, user_id: str, scenario_key: str, topic_key: str) -> bool:
         """
         记录用户在该场景该话题下进行了对话。同一 (user_id, scenario_key, topic_key) 只插入一次。

+ 17 - 0
backend/services/user_service.py

@@ -481,6 +481,23 @@ class UserService:
                 f"error={e}, 耗时={total_duration:.1f}ms"
             )
             return False
+
+    async def update_preferred_language(self, user_id: str, preferred_language: str) -> bool:
+        """更新用户首选界面语言(zh/en)"""
+        if preferred_language not in ("zh", "en"):
+            return False
+        try:
+            async with db_manager.get_session() as session:
+                result = await session.execute(
+                    update(User)
+                    .where(User.user_id == user_id)
+                    .values(preferred_language=preferred_language)
+                )
+                await session.commit()
+                return result.rowcount > 0
+        except Exception as e:
+            logger.error(f"❌ 更新首选语言失败: user_id={user_id}, error={e}")
+            return False
     
     async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[Dict[str, int]]:
         """

+ 145 - 0
frontend/src/components/LanguageSelector.tsx

@@ -0,0 +1,145 @@
+/**
+ * 语言下拉(与 login-register 页同款样式,默认 mvp 375 设计稿 px2vmin)
+ */
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { px2vmin } from '@/utils/viewport';
+import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
+
+const arrowIcon = getOssUrl('arrow-icon.png');
+
+export interface LanguageSelectorProps {
+  /** 埋点 pageId,如 login_register、profile */
+  trackingPageId: string;
+  /** 展开按钮埋点 id,默认 lang_toggle */
+  trackingToggleId?: string;
+  /** 选择某项埋点 id,默认 lang_select */
+  trackingSelectId?: string;
+  /** 选择语言后回调(由调用方负责 i18n.changeLanguage / 调接口等) */
+  onSelect: (lang: 'zh' | 'en') => void | Promise<void>;
+}
+
+export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
+  trackingPageId,
+  trackingToggleId = 'lang_toggle',
+  trackingSelectId = 'lang_select',
+  onSelect,
+}) => {
+  const { i18n } = useTranslation();
+  const [open, setOpen] = useState(false);
+
+  const currentLang = i18n.language.startsWith('zh') ? 'zh' : 'en';
+
+  const handlePick = async (next: 'zh' | 'en') => {
+    if (next === currentLang) {
+      setOpen(false);
+      return;
+    }
+    setOpen(false);
+    await Promise.resolve(onSelect(next));
+  };
+
+  const buttonLabel = currentLang === 'zh' ? '中文' : 'En';
+
+  const otherOptionCode: 'zh' | 'en' = currentLang === 'zh' ? 'en' : 'zh';
+  const otherOptionMenuLabel = otherOptionCode === 'zh' ? '中文' : 'English';
+
+  return (
+    <div
+      style={{
+        position: 'relative',
+        width: px2vmin(81),
+        height: px2vmin(36),
+        flexShrink: 0,
+      }}
+    >
+      <div style={{ position: 'relative', width: '100%', height: '100%' }}>
+        <button
+          type="button"
+          onClick={wrapTrackedClick(trackingPageId, trackingToggleId, () => setOpen((prev) => !prev))}
+          aria-haspopup="listbox"
+          aria-expanded={open}
+          style={{
+            position: 'relative',
+            width: '100%',
+            height: '100%',
+            borderRadius: '12PX',
+            border: 'none',
+            padding: 0,
+            background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
+            boxShadow:
+              '0PX 1PX 0PX 0PX rgba(255, 255, 255, 0.18), inset 0PX 0PX 0PX 1PX rgba(0, 0, 0, 0.65), inset 0PX 4PX 8PX rgba(0, 0, 0, 0.55)',
+            display: 'flex',
+            alignItems: 'center',
+            justifyContent: 'center',
+            cursor: 'pointer',
+            paddingLeft: px2vmin(12),
+            paddingRight: px2vmin(12),
+          }}
+        >
+          <span
+            style={{
+              fontFamily:
+                '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+              lineHeight: px2vmin(24),
+              marginRight: px2vmin(10),
+              color: '#FFFFFF',
+            }}
+          >
+            {buttonLabel}
+          </span>
+          <img
+            src={arrowIcon}
+            alt=""
+            style={{
+              position: 'absolute',
+              right: px2vmin(20),
+              top: '50%',
+              transform: `translateY(-50%) ${open ? 'rotate(180deg)' : ''}`,
+              width: px2vmin(7.5),
+              height: px2vmin(3.75),
+              transition: 'transform 0.15s ease-out',
+            }}
+            draggable={false}
+          />
+        </button>
+        {open && (
+          <div
+            style={{
+              position: 'absolute',
+              top: '100%',
+              left: 0,
+              marginTop: px2vmin(4),
+              width: '100%',
+              borderRadius: '12PX',
+              background: 'transparent',
+              boxShadow: '0PX 8PX 20PX rgba(0, 0, 0, 0.45)',
+              overflow: 'hidden',
+              zIndex: 20,
+            }}
+          >
+            <button
+              type="button"
+              onClick={wrapTrackedClick(trackingPageId, trackingSelectId, () => {
+                void handlePick(otherOptionCode);
+              })}
+              style={{
+                width: '100%',
+                border: 'none',
+                padding: `${px2vmin(8)} ${px2vmin(12)}`,
+                background: 'transparent',
+                color: '#FFFFFF',
+                fontSize: px2vmin(14),
+                textAlign: 'left',
+                cursor: 'pointer',
+              }}
+            >
+              {otherOptionMenuLabel}
+            </button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};

+ 149 - 47
frontend/src/components/MissionEnergyStoneModal.tsx

@@ -1,4 +1,5 @@
-import React, { useEffect, useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import './MissionEnergyStoneModal.css';
@@ -7,11 +8,12 @@ import { wrapTrackedClick } from '@/utils/tracking';
 
 const missionEnergyStoneImage = getOssUrl('Mission-Energy-Stone.png');
 const conversationEnergyStoneImage = getOssUrl('Conversation-Energy-Stone.png');
+const secondaryDefaultButton = getOssUrl('secondary-default.png');
 
 interface MissionEnergyStoneModalProps {
   visible: boolean;
   onClose: () => void;
-  stoneType?: 'mission' | 'conversation';
+  stoneType?: 'mission' | 'conversation' | 'lumi_home';
   children?: React.ReactNode;
   /** 埋点用页面 ID,传入时关闭遮罩会上报 button.<pageId>.<closeId>__click */
   trackingPageId?: string;
@@ -27,6 +29,7 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
   trackingPageId,
   trackingCloseId = 'energy_stone_close',
 }) => {
+  const navigate = useNavigate();
   const handleClose = useMemo(
     () =>
       trackingPageId
@@ -34,7 +37,22 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
         : onClose,
     [trackingPageId, trackingCloseId, onClose]
   );
+  const handleChatNow = useCallback(() => {
+    onClose();
+    navigate('/chatbot');
+  }, [onClose, navigate]);
+  const onChatNowClick = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, 'chat_now', handleChatNow)
+        : handleChatNow,
+    [trackingPageId, handleChatNow]
+  );
   const { t } = useTranslation('profile');
+  const isLumiHome = stoneType === 'lumi_home';
+  const isMission = stoneType === 'mission';
+  const showChatButton =
+    stoneType === 'mission' || stoneType === 'conversation' || stoneType === 'lumi_home';
   const imageSrc = stoneType === 'mission' ? missionEnergyStoneImage : conversationEnergyStoneImage;
   const title = stoneType === 'mission' ? t('missionStoneTitle') : t('chatStoneTitle');
   const description = stoneType === 'mission' ? t('missionStoneDescription') : t('chatStoneDescription');
@@ -81,17 +99,31 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
           left: '50%',
           transform: 'translateX(-50%)',
           width: px2vw(375),
-          height: px2vmin(192),
           backgroundColor: '#34313D',
           borderTopLeftRadius: px2vmin(32),
           borderTopRightRadius: px2vmin(32),
           zIndex: 9999,
-          // animation: 'slideUp 0.3s ease-out',
+          boxSizing: 'border-box',
           display: 'flex',
           flexDirection: 'column',
           alignItems: 'center',
-          // justifyContent: 'flex-start',
           paddingTop: px2vmin(12),
+          paddingBottom: px2vmin(32),
+          ...(isLumiHome
+            ? {
+                height: 'auto',
+                maxHeight: 'min(88vh, 480px)',
+                overflowY: 'auto',
+                WebkitOverflowScrolling: 'touch',
+              }
+            : stoneType === 'conversation'
+              ? { height: px2vmin(272) }
+              : {
+                  height: 'auto',
+                  maxHeight: 'min(88vh, 520px)',
+                  overflowY: 'auto',
+                  WebkitOverflowScrolling: 'touch',
+                }),
         }}
       >
         {/* 顶部元素 */}
@@ -105,64 +137,134 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
         />
 
         {/* 主要内容区域 */}
-        <div
-          style={{
-            marginTop: px2vmin(54 - 12 - 4), // 距离弹窗顶部54px,减去paddingTop 12px和顶部元素高度4px
-            // width: px2vmin(311),
-            height: px2vmin(100),
-            display: 'flex',
-            flexDirection: 'row',
-            alignItems: 'center',
-            gap: px2vmin(32),
-            marginLeft: px2vmin(32),
-            marginRight: px2vmin(32),
-          }}
-        >
-          {/* 左边图片 */}
-          <img
-            src={imageSrc}
-            alt={title}
+        {isLumiHome ? (
+          <div
             style={{
-              width: px2vmin(80),
-              height: px2vmin(80),
-              flexShrink: 0,
+              alignSelf: 'stretch',
+              marginTop: px2vmin(54 - 12 - 4),
+              marginLeft: px2vmin(32),
+              marginRight: px2vmin(32),
+              color: '#FFFFFF',
+              fontSize: px2vmin(14),
+              fontWeight: 400,
+              lineHeight: 1.4,
+              textAlign: 'left',
             }}
-          />
-
-          {/* 右边文字区域 */}
+          >
+            {t('lumiHomeModalBody')}
+          </div>
+        ) : (
           <div
             style={{
-              flex: 1,
+              alignSelf: 'stretch',
+              marginTop: px2vmin(54 - 12 - 4),
+              marginLeft: px2vmin(32),
+              marginRight: px2vmin(32),
               display: 'flex',
-              flexDirection: 'column',
-              justifyContent: 'center',
-              gap: px2vmin(8),
+              flexDirection: 'row',
+              alignItems: 'flex-start',
+              gap: px2vmin(32),
+              minHeight: px2vmin(100),
+              boxSizing: 'border-box',
             }}
           >
-            {/* 上半部分:标题 */}
-            <div
+            <img
+              src={imageSrc}
+              alt={title}
               style={{
-                color: '#FFFFFF',
-                fontSize: px2vmin(20),
-                fontWeight: 'bold',
-                lineHeight: 1.2,
+                width: px2vmin(80),
+                height: px2vmin(80),
+                flexShrink: 0,
               }}
-            >
-              {title}
-            </div>
+            />
 
-            {/* 下半部分:描述 */}
             <div
               style={{
-                color: '#FFFFFF',
-                fontSize: px2vmin(14),
-                lineHeight: 1.4,
+                flex: 1,
+                display: 'flex',
+                flexDirection: 'column',
+                justifyContent: 'center',
+                gap: px2vmin(8),
+                minWidth: 0,
               }}
             >
-              {description}
+              {isMission ? (
+                <div
+                  style={{
+                    color: '#FFFFFF',
+                    fontSize: px2vmin(14),
+                    lineHeight: 1.4,
+                    fontWeight: 400,
+                    textAlign: 'left',
+                  }}
+                >
+                  {description}
+                </div>
+              ) : (
+                <>
+                  <div
+                    style={{
+                      color: '#FFFFFF',
+                      fontSize: px2vmin(20),
+                      fontWeight: 'bold',
+                      lineHeight: 1.2,
+                    }}
+                  >
+                    {title}
+                  </div>
+
+                  <div
+                    style={{
+                      color: '#FFFFFF',
+                      fontSize: px2vmin(14),
+                      lineHeight: 1.4,
+                      whiteSpace: 'pre-line',
+                    }}
+                  >
+                    {description}
+                  </div>
+                </>
+              )}
             </div>
           </div>
-        </div>
+        )}
+
+        {showChatButton ? (
+          <button
+            type="button"
+            onClick={onChatNowClick}
+            onKeyDown={(e) => {
+              if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                onChatNowClick();
+              }
+            }}
+            style={{
+              marginTop: px2vmin(30),
+              marginLeft: 'auto',
+              marginRight: 'auto',
+              width: px2vmin(312),
+              height: px2vmin(56),
+              padding: 0,
+              border: 'none',
+              borderRadius: px2vmin(12),
+              background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+              cursor: 'pointer',
+              transition: 'opacity 0.2s ease',
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+              fontWeight: 500,
+              fontSize: px2vmin(16),
+              lineHeight: px2vmin(24),
+              color: '#fff',
+              WebkitTapHighlightColor: 'transparent',
+            }}
+          >
+            {t('chatNowButton')}
+          </button>
+        ) : null}
 
         {children}
       </div>

+ 1 - 0
frontend/src/i18n/locales/en/error.json

@@ -12,6 +12,7 @@
   "UPDATE_BG_IMAGE_FAILED": "Failed to update background image",
   "UPDATE_BOT_NAME_FAILED": "Failed to update bot name",
   "UPDATE_BOT_NAME_TIMEOUT": "Update timed out, please try again later",
+  "UPDATE_PREFERRED_LANGUAGE_FAILED": "Failed to update language preference",
   "GET_ENERGY_STONES_FAILED": "Failed to get energy stones",
   "GET_AVATARS_FAILED": "Failed to get avatars",
   "GET_BG_CONFIG_FAILED": "Failed to get background config",

+ 1 - 1
frontend/src/i18n/locales/en/profile.json

@@ -1 +1 @@
-{"changeMyName":"Change my name","changeLumiName":"Change Lumi's name","change":"Change","placeholderUsername":"Change my name","placeholderLumiName":"Change Lumi's name","head":"Head","background":"Background","feedbackAndHelp":"Feedback & Help","userAgreement":"User Agreement","privacyPolicy":"Privacy Policy","missionStoneTitle":"Mission Energy Stone","missionStoneDescription":"Earn this Energy Stone by completing tasks to save the Life Tree. Watch for hidden tasks!","chatStoneTitle":"Energy Stone","chatStoneDescription":"Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Energy Stone."}
+{"lumiAgeLabel":"Age","lumiBondLabel":"Bond Level","lumiBagTitle":"Lumi's Bag","lumiBagShop":"Shop","lumiBagFurniture":"Furniture","lumiBagOutfits":"Outfits","userCollectionLabel":"{{name}}'s Collection","worldExplorationTitle":"World","journalTitle":"Journal","journalMoreHint":"Coming soon","lumiHomeLabel":"Lumi's Home","lumiHomeModalBody":"Welcome to Lumi's home. Start chatting to discover the little mysteries of Lumi's daily life.","languageLabel":"Language","tabMe":"Me","tabLumi":"Lumi","lumiTabPlaceholder":"More coming soon","changeMyName":"Change my name","changeLumiName":"Change Lumi's name","change":"Change","placeholderUsername":"Change my name","placeholderLumiName":"Change Lumi's name","head":"Head","background":"Background","feedbackAndHelp":"Feedback & Help","userAgreement":"User Agreement","privacyPolicy":"Privacy Policy","missionStoneTitle":"Crystal Stone","missionStoneDescription":"Crystal stones are still being mined... This is a special underground ore. Later, you can use it to purchase special items or exchange it directly for Energy Stones.","chatStoneTitle":"Energy Stone","chatNowButton":"Chat Now","chatStoneDescription":"Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Energy Stone.","lumiOutfitsShopHint":"The outfit shop is under renovation. We look forward to seeing you when it opens.","lumiOutfitsCapCardToggleA11y":"Toggle hat outfit"}

+ 1 - 0
frontend/src/i18n/locales/zh/error.json

@@ -12,6 +12,7 @@
   "UPDATE_BG_IMAGE_FAILED": "更新背景图片ID失败",
   "UPDATE_BOT_NAME_FAILED": "更新机器人名字失败",
   "UPDATE_BOT_NAME_TIMEOUT": "更新机器人名字超时,请稍后重试",
+  "UPDATE_PREFERRED_LANGUAGE_FAILED": "更新语言偏好失败",
   "GET_ENERGY_STONES_FAILED": "获取能量石数量失败",
   "GET_AVATARS_FAILED": "获取用户头像信息失败",
   "GET_BG_CONFIG_FAILED": "获取背景图片配置失败",

+ 1 - 1
frontend/src/i18n/locales/zh/profile.json

@@ -1 +1 @@
-{"changeMyName":"修改我的名字","changeLumiName":"修改 Lumi 的名字","change":"修改","placeholderUsername":"修改我的名字","placeholderLumiName":"修改 Lumi 的名字","head":"头部","background":"背景","feedbackAndHelp":"意见反馈 & 帮助","userAgreement":"使用协议","privacyPolicy":"隐私政策","missionStoneTitle":"任务能量石","missionStoneDescription":"你可以通过完成各种任务获得该能量石,用于拯救生命树。注意,可能有隐藏任务哦!","chatStoneTitle":"能量石","chatStoneDescription":"你可以通过和Lumi的对话、完成任务获得能量石,用于购买商品。累计完成30s语音输入,可以获得一块能量石。"}
+{"lumiAgeLabel":"年龄","lumiBondLabel":"亲密度","lumiBagTitle":"Lumi的背包","lumiBagShop":"商店","lumiBagFurniture":"家具","lumiBagOutfits":"服装","userCollectionLabel":"{{name}}的收藏","worldExplorationTitle":"世界探索","journalTitle":"日记本","journalMoreHint":"即将解锁更多日记","lumiHomeLabel":"Lumi的家","lumiHomeModalBody":"这里是 Lumi 的温馨小家。通过聊天,发现她日常生活中的神秘惊喜。","languageLabel":"语言","tabMe":"我","tabLumi":"Lumi","lumiTabPlaceholder":"内容敬请期待","changeMyName":"修改我的名字","changeLumiName":"修改 Lumi 的名字","change":"修改","placeholderUsername":"修改我的名字","placeholderLumiName":"修改 Lumi 的名字","head":"头部","background":"背景","feedbackAndHelp":"意见反馈 & 帮助","userAgreement":"使用协议","privacyPolicy":"隐私政策","missionStoneTitle":"水晶石","missionStoneDescription":"水晶石正在挖掘中... 这是一种特殊的地下矿石,后续可用于购买特殊商品,或者直接兑换能量石。","chatStoneTitle":"能量石","chatNowButton":"去聊天","chatStoneDescription":"你可以通过和Lumi的对话、完成任务获得能量石,用于购买商品。累计完成30s语音输入,可以获得一块能量石。","lumiOutfitsShopHint":"服饰商店正在装修中,届时欢迎前来选购","lumiOutfitsCapCardToggleA11y":"切换是否使用戴帽形象"}

+ 17 - 123
frontend/src/pages/LoginRegisterPage.tsx

@@ -12,141 +12,21 @@ const DESIGN: ViewportDesign = 'v2';
 import { getOssUrl } from '@/utils/oss';
 import { UserAgreementModal } from '@/components/UserAgreementModal';
 import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
+import { LanguageSelector } from '@/components/LanguageSelector';
 import { wrapTrackedClick } from '@/utils/tracking';
 
 const lumiImage = getOssUrl('01-Default.gif');
 const primaryBtn = getOssUrl('primary-default.png');
 const secondaryBtn = getOssUrl('secondary-default.png');
-const arrowIcon = getOssUrl('arrow-icon.png');
 
 /** 设计稿 393×852:第一个按钮 top 514,按钮高 52,间距 20 → 第二个按钮 top */
 const BUTTON_HEIGHT = 52;
 const BUTTON_GAP = 20;
 const SECOND_BUTTON_TOP = 514 + BUTTON_HEIGHT + BUTTON_GAP;
 
-interface LoginLanguageSelectorProps {
-  /** 设计稿:定位用的视口设计配置 */
-  design: ViewportDesign;
-}
-
-const LoginLanguageSelector: React.FC<LoginLanguageSelectorProps> = ({ design }) => {
-  const { i18n } = useTranslation();
-  const [open, setOpen] = useState(false);
-
-  const currentLang = i18n.language.startsWith('zh') ? 'zh' : 'en';
-
-  const handleChange = (next: 'zh' | 'en') => {
-    if (next === currentLang) return;
-    i18n.changeLanguage(next);
-    setOpen(false);
-  };
-
-  const buttonLabel = currentLang === 'zh' ? '中文' : 'En';
-
-  const otherOptionCode: 'zh' | 'en' = currentLang === 'zh' ? 'en' : 'zh';
-  const otherOptionMenuLabel = otherOptionCode === 'zh' ? '中文' : 'English';
-
-  return (
-    <div
-      style={{
-        position: 'absolute',
-        top: px2vmin(126, design),
-        left: px2vmin(284, design),
-        width: px2vmin(81, design),
-        height: px2vmin(36, design),
-        zIndex: 10,
-      }}
-    >
-      <div style={{ position: 'relative', width: '100%', height: '100%' }}>
-        <button
-          type="button"
-          onClick={wrapTrackedClick('login_register', 'lang_toggle', () => setOpen((prev) => !prev))}
-          aria-haspopup="listbox"
-          aria-expanded={open}
-          style={{
-            position: 'relative',
-            width: '100%',
-            height: '100%',
-            borderRadius: '12PX',
-            border: 'none',
-            padding: 0,
-            background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
-            boxShadow:
-              '0PX 1PX 0PX 0PX rgba(255, 255, 255, 0.18), inset 0PX 0PX 0PX 1PX rgba(0, 0, 0, 0.65), inset 0PX 4PX 8PX rgba(0, 0, 0, 0.55)',
-            display: 'flex',
-            alignItems: 'center',
-            justifyContent: 'center',
-            cursor: 'pointer',
-            paddingLeft: px2vmin(12, design),
-            paddingRight: px2vmin(12, design),
-          }}
-        >
-          <span
-            style={{
-              fontFamily:
-                '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
-              lineHeight: px2vmin(24, design),
-              marginRight: px2vmin(10, design),
-              color: '#FFFFFF',
-            }}
-          >
-            {buttonLabel}
-          </span>
-          <img
-            src={arrowIcon}
-            alt=""
-            style={{
-              position: 'absolute',
-              right: px2vmin(20, design),
-              top: '50%',
-              transform: `translateY(-50%) ${open ? 'rotate(180deg)' : ''}`,
-              width: px2vmin(7.5, design),
-              height: px2vmin(3.75, design),
-              transition: 'transform 0.15s ease-out',
-            }}
-            draggable={false}
-          />
-        </button>
-        {open && (
-          <div
-            style={{
-              position: 'absolute',
-              top: '100%',
-              left: 0,
-              marginTop: px2vmin(4, design),
-              width: '100%',
-              borderRadius: '12PX',
-              background: 'transparent',
-              boxShadow: '0PX 8PX 20PX rgba(0, 0, 0, 0.45)',
-              overflow: 'hidden',
-            }}
-          >
-            <button
-              type="button"
-              onClick={wrapTrackedClick('login_register', 'lang_select', () => handleChange(otherOptionCode))}
-              style={{
-                width: '100%',
-                border: 'none',
-                padding: `${px2vmin(8, design)} ${px2vmin(12, design)}`,
-                background: 'transparent',
-                color: '#FFFFFF',
-                fontSize: px2vmin(14, design),
-                textAlign: 'left',
-                cursor: 'pointer',
-              }}
-            >
-              {otherOptionMenuLabel}
-            </button>
-          </div>
-        )}
-      </div>
-    </div>
-  );
-};
-
 export const LoginRegisterPage = () => {
   const navigate = useNavigate();
-  const { t } = useTranslation('login');
+  const { t, i18n } = useTranslation('login');
   const [agreeTerms, setAgreeTerms] = useState(true);
   const [showAgreementModal, setShowAgreementModal] = useState(false);
   const [showPrivacyPolicyModal, setShowPrivacyPolicyModal] = useState(false);
@@ -190,7 +70,21 @@ export const LoginRegisterPage = () => {
             zIndex: 1,
           } as React.CSSProperties & { height: string }}
         >
-          <LoginLanguageSelector design={DESIGN} />
+          <div
+            style={{
+              position: 'absolute',
+              top: px2vmin(126, DESIGN),
+              left: px2vmin(284, DESIGN),
+              zIndex: 10,
+            }}
+          >
+            <LanguageSelector
+              trackingPageId="login_register"
+              onSelect={(next) => {
+                void i18n.changeLanguage(next);
+              }}
+            />
+          </div>
           {/* Hello:设计稿 393×852 距左 41、距顶 150、宽 245 */}
           <div
             style={{

+ 567 - 0
frontend/src/pages/LumiOutfitsPage.tsx

@@ -0,0 +1,567 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { CSSProperties } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { px2vmin, px2vw } from '@/utils/viewport';
+import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
+import { onScreenResize, updateTooSmallFlag } from '@/utils/screenSize';
+import { STOOL_LAYOUT } from '@/constants/chatbot';
+import { updateUserUIState } from '@/utils/api';
+import { getStorage, getUserId, setCurrentAvatarId as persistAvatarIdToStorage, STORAGE_KEYS } from '@/utils/storage';
+import '@/styles/chatbot-fix.css';
+
+const treesImageUrl = getOssUrl('init3.png');
+const stoolImageUrl = getOssUrl('stool.png');
+const backButtonIcon = getOssUrl('backbutn-icon.png');
+const lumiOutfitImg = getOssUrl('lumi-outfit-img.png');
+const headCapImg = getOssUrl('head-cap.png');
+const wenhaoImg = getOssUrl('wenhao.png');
+const secondaryDefaultButton = getOssUrl('secondary-default.png');
+/** 与 Profile Lumi 背包「家具 / 服装」卡片一致 */
+const chairBagImg = getOssUrl('Chair.png');
+const glovesBagImg = getOssUrl('Gloves.png');
+
+/** 与 ChatBotPage 一致:默认形象(无帽) / 注册赠送(戴帽) */
+const AVATAR_ID_DEFAULT = 'avatar_default';
+const AVATAR_ID_REGISTERED = 'avatar_registered_cap';
+
+/** 与 ChatBotPage 中 getTreesImageStyle 一致(/chatbot 默认前景,含窗户与树木) */
+const getTreesImageStyle = (): CSSProperties => ({
+  width: '100%',
+  height: '100%',
+  top: 0,
+  left: 0,
+  objectFit: 'cover',
+  zIndex: 1,
+  opacity: 1,
+});
+
+export const LumiOutfitsPage = () => {
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+  const { t } = useTranslation('common');
+  const { t: tProfile } = useTranslation('profile');
+
+  const [currentAvatarId, setCurrentAvatarId] = useState<string | null>(() =>
+    getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID),
+  );
+
+  useEffect(() => {
+    updateTooSmallFlag();
+    return onScreenResize(() => {
+      updateTooSmallFlag();
+    });
+  }, []);
+
+  useEffect(() => {
+    const sync = () => {
+      setCurrentAvatarId(getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID));
+    };
+    const onStorage = (e: StorageEvent) => {
+      if (e.key === STORAGE_KEYS.CURRENT_AVATAR_ID) {
+        sync();
+      }
+    };
+    window.addEventListener('storage', onStorage);
+    window.addEventListener('focus', sync);
+    return () => {
+      window.removeEventListener('storage', onStorage);
+      window.removeEventListener('focus', sync);
+    };
+  }, []);
+
+  const capCardBorderStyle = useMemo((): CSSProperties => {
+    if (currentAvatarId === AVATAR_ID_REGISTERED) {
+      return { border: '1PX solid #BF9DF8' };
+    }
+    return { border: 'none' };
+  }, [currentAvatarId]);
+
+  const handleBack = useCallback(() => {
+    navigate(-1);
+  }, [navigate]);
+
+  const onBackClick = useMemo(
+    () => wrapTrackedClick('lumi_outfits', 'back', handleBack),
+    [handleBack],
+  );
+
+  const handleChatNow = useCallback(() => {
+    navigate('/chatbot');
+  }, [navigate]);
+
+  const onChatNowClick = useMemo(
+    () => wrapTrackedClick('lumi_outfits', 'chat_now', handleChatNow),
+    [handleChatNow],
+  );
+
+  const handleToggleCapOutfit = useCallback(() => {
+    const next =
+      currentAvatarId === AVATAR_ID_REGISTERED ? AVATAR_ID_DEFAULT : AVATAR_ID_REGISTERED;
+    persistAvatarIdToStorage(next);
+    setCurrentAvatarId(next);
+    const userId = getUserId();
+    if (userId) {
+      void updateUserUIState(userId, { current_avatar_id: next }).catch(() => {
+        // 本地已更新,后端同步失败不阻断切换
+      });
+    }
+  }, [currentAvatarId]);
+
+  const onCapCardClick = useMemo(
+    () => wrapTrackedClick('lumi_outfits', 'cap_outfit_card', handleToggleCapOutfit),
+    [handleToggleCapOutfit],
+  );
+
+  const [lumiBagTab, setLumiBagTab] = useState<'furniture' | 'outfits'>('outfits');
+
+  useEffect(() => {
+    const tab = searchParams.get('tab');
+    setLumiBagTab(tab === 'furniture' ? 'furniture' : 'outfits');
+  }, [searchParams]);
+
+  const handleSelectFurnitureTab = useCallback(() => {
+    setLumiBagTab('furniture');
+  }, []);
+
+  const handleSelectOutfitsTab = useCallback(() => {
+    setLumiBagTab('outfits');
+  }, []);
+
+  const onFurnitureTabClick = useMemo(
+    () => wrapTrackedClick('lumi_outfits', 'bag_furniture_tab', handleSelectFurnitureTab),
+    [handleSelectFurnitureTab],
+  );
+
+  const onOutfitsTabClick = useMemo(
+    () => wrapTrackedClick('lumi_outfits', 'bag_outfits_tab', handleSelectOutfitsTab),
+    [handleSelectOutfitsTab],
+  );
+
+  return (
+    <>
+      <div
+        className="chatbot-container"
+        style={{
+          position: 'relative',
+          width: '100%',
+          minHeight: '100dvh',
+          overflow: 'hidden',
+        }}
+      >
+        <img
+          src={treesImageUrl}
+          alt={t('a11y.trees')}
+          className="chatbot-trees absolute pointer-events-none"
+          style={getTreesImageStyle()}
+          fetchPriority="low"
+          loading="lazy"
+          draggable={false}
+        />
+
+        <img
+          src={stoolImageUrl}
+          alt=""
+          className="absolute pointer-events-none"
+          fetchPriority="low"
+          style={{
+            width: px2vmin(STOOL_LAYOUT.width),
+            height: px2vmin(STOOL_LAYOUT.height),
+            left: px2vmin(STOOL_LAYOUT.left),
+            top: px2vmin(STOOL_LAYOUT.top),
+            zIndex: 2,
+            objectFit: 'contain',
+          }}
+          draggable={false}
+        />
+
+        {lumiBagTab === 'outfits' && (
+          <img
+            src={lumiOutfitImg}
+            alt=""
+            className="absolute pointer-events-none"
+            style={{
+              width: px2vmin(240),
+              height: px2vmin(240),
+              left: '50%',
+              top: px2vmin(231),
+              transform: 'translateX(-50%)',
+              zIndex: 5,
+              objectFit: 'contain',
+            }}
+            draggable={false}
+          />
+        )}
+
+        <button
+          type="button"
+          onClick={onBackClick}
+          style={{
+            position: 'fixed',
+            left: px2vmin(24),
+            top: px2vmin(125),
+            width: px2vmin(32),
+            height: px2vmin(32),
+            padding: 0,
+            border: 'none',
+            background: 'none',
+            borderRadius: '50%',
+            zIndex: 100,
+            cursor: 'pointer',
+          }}
+          aria-label={t('a11y.back')}
+        >
+          <img
+            src={backButtonIcon}
+            alt=""
+            style={{
+              width: '100%',
+              height: '100%',
+              display: 'block',
+              pointerEvents: 'none',
+            }}
+            draggable={false}
+          />
+        </button>
+      </div>
+
+      {/* 底部弹窗 + 上方家具/服装切换(与 Profile Lumi 背包 icon 一致) */}
+      <div
+        style={{
+          position: 'fixed',
+          bottom: 0,
+          left: '50%',
+          transform: 'translateX(-50%)',
+          width: px2vw(375),
+          zIndex: 9999,
+          boxSizing: 'border-box',
+        }}
+      >
+        <div
+          style={{
+            paddingLeft: px2vmin(24),
+            marginBottom: px2vmin(10),
+            display: 'flex',
+            justifyContent: 'flex-start',
+          }}
+        >
+          <div
+            role="tablist"
+            aria-label={tProfile('lumiBagTitle')}
+            style={{
+              width: px2vmin(80),
+              height: px2vmin(32),
+              borderRadius: px2vmin(12),
+              backgroundColor: 'rgba(0, 0, 0, 0.2)',
+              boxSizing: 'border-box',
+              display: 'flex',
+              flexDirection: 'row',
+              alignItems: 'stretch',
+              padding: px2vmin(2),
+              gap: px2vmin(2),
+            }}
+          >
+            <button
+              type="button"
+              role="tab"
+              aria-selected={lumiBagTab === 'furniture'}
+              onClick={onFurnitureTabClick}
+              style={{
+                flex: 1,
+                minWidth: 0,
+                margin: 0,
+                padding: 0,
+                border: 'none',
+                cursor: 'pointer',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                borderRadius: px2vmin(10),
+                backgroundColor:
+                  lumiBagTab === 'furniture' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              aria-label={tProfile('lumiBagFurniture')}
+            >
+              <img
+                src={chairBagImg}
+                alt=""
+                draggable={false}
+                style={{
+                  width: px2vmin(20),
+                  height: px2vmin(20),
+                  objectFit: 'contain',
+                  display: 'block',
+                  pointerEvents: 'none',
+                }}
+              />
+            </button>
+            <button
+              type="button"
+              role="tab"
+              aria-selected={lumiBagTab === 'outfits'}
+              onClick={onOutfitsTabClick}
+              style={{
+                flex: 1,
+                minWidth: 0,
+                margin: 0,
+                padding: 0,
+                border: 'none',
+                cursor: 'pointer',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                borderRadius: px2vmin(10),
+                backgroundColor:
+                  lumiBagTab === 'outfits' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+              aria-label={tProfile('lumiBagOutfits')}
+            >
+              <img
+                src={glovesBagImg}
+                alt=""
+                draggable={false}
+                style={{
+                  width: px2vmin(20),
+                  height: px2vmin(20),
+                  objectFit: 'contain',
+                  display: 'block',
+                  pointerEvents: 'none',
+                }}
+              />
+            </button>
+          </div>
+        </div>
+
+        <div
+          style={{
+            height: px2vmin(318),
+            backgroundColor: '#34313D',
+            borderTopLeftRadius: px2vmin(32),
+            borderTopRightRadius: px2vmin(32),
+            boxSizing: 'border-box',
+            paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 0px)',
+          }}
+        >
+        <div
+          style={{
+            position: 'relative',
+            width: '100%',
+            height: '100%',
+            overflow: 'hidden',
+          }}
+        >
+          {/* 文字距弹窗顶 194(vmin);提示文案 + 去聊天 */}
+          <div
+            style={{
+              position: 'absolute',
+              top: px2vmin(194),
+              left: 0,
+              right: 0,
+              display: 'flex',
+              flexDirection: 'column',
+              alignItems: 'center',
+              paddingLeft: px2vmin(32),
+              paddingRight: px2vmin(32),
+              boxSizing: 'border-box',
+              zIndex: 10,
+            }}
+          >
+            <p
+              style={{
+                margin: 0,
+                width: '100%',
+                color: '#FFFFFF',
+                fontSize: px2vmin(14),
+                lineHeight: px2vmin(24),
+                textAlign: 'center',
+                fontWeight: 400,
+              }}
+            >
+              {tProfile('lumiOutfitsShopHint')}
+            </p>
+            <button
+              type="button"
+              onClick={onChatNowClick}
+              onKeyDown={(e) => {
+                if (e.key === 'Enter' || e.key === ' ') {
+                  e.preventDefault();
+                  onChatNowClick();
+                }
+              }}
+              style={{
+                marginTop: px2vmin(10),
+                width: px2vmin(312),
+                height: px2vmin(56),
+                padding: 0,
+                border: 'none',
+                borderRadius: px2vmin(12),
+                background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
+                cursor: 'pointer',
+                transition: 'opacity 0.2s ease',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                fontFamily:
+                  '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
+                fontWeight: 500,
+                fontSize: px2vmin(16),
+                lineHeight: px2vmin(24),
+                color: '#fff',
+                WebkitTapHighlightColor: 'transparent',
+              }}
+            >
+              {tProfile('chatNowButton')}
+            </button>
+          </div>
+
+          {/* 顶部 44:与能量石弹窗一致留出手势条区域 */}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              right: 0,
+              height: px2vmin(44),
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'center',
+              pointerEvents: 'none',
+            }}
+          >
+            <div
+              style={{
+                width: px2vmin(32),
+                height: px2vmin(4),
+                backgroundColor: '#79747E',
+                borderRadius: px2vmin(4),
+              }}
+            />
+          </div>
+
+          {/* 距弹窗顶部 44、高度 274(随弹窗总高 318) */}
+          <div
+            style={{
+              position: 'absolute',
+              top: px2vmin(44),
+              left: 0,
+              width: '100%',
+              height: px2vmin(274),
+              boxSizing: 'border-box',
+            }}
+          >
+            {/** 服装行:距父区域顶部 32,宽 310,高 90,水平居中 */}
+            <div
+              style={{
+                marginTop: px2vmin(32),
+                marginLeft: 'auto',
+                marginRight: 'auto',
+                width: px2vmin(310),
+                height: px2vmin(90),
+                display: 'flex',
+                flexDirection: 'row',
+                alignItems: 'center',
+                justifyContent: 'center',
+                columnGap: px2vmin(20),
+                boxSizing: 'border-box',
+              }}
+            >
+              {lumiBagTab === 'furniture' ? (
+                <p
+                  style={{
+                    margin: 0,
+                    width: '100%',
+                    textAlign: 'center',
+                    color: 'rgba(255, 255, 255, 0.6)',
+                    fontSize: px2vmin(14),
+                    lineHeight: px2vmin(20),
+                  }}
+                >
+                  {tProfile('lumiTabPlaceholder')}
+                </p>
+              ) : (
+                <>
+              {/* 卡片 1:帽子 — 点击在戴帽/默认形象间切换 current_avatar_id */}
+              <button
+                type="button"
+                onClick={onCapCardClick}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter' || e.key === ' ') {
+                    e.preventDefault();
+                    onCapCardClick();
+                  }
+                }}
+                style={{
+                  width: px2vmin(90),
+                  height: px2vmin(90),
+                  borderRadius: px2vmin(16),
+                  backgroundColor: 'rgba(245, 243, 240, 0.1)',
+                  boxSizing: 'border-box',
+                  display: 'flex',
+                  alignItems: 'center',
+                  justifyContent: 'center',
+                  flexShrink: 0,
+                  padding: 0,
+                  border: 'none',
+                  cursor: 'pointer',
+                  WebkitTapHighlightColor: 'transparent',
+                  ...capCardBorderStyle,
+                }}
+                aria-pressed={currentAvatarId === AVATAR_ID_REGISTERED}
+                aria-label={tProfile('lumiOutfitsCapCardToggleA11y')}
+              >
+                <img
+                  src={headCapImg}
+                  alt=""
+                  draggable={false}
+                  style={{
+                    width: px2vmin(64),
+                    height: px2vmin(64),
+                    objectFit: 'contain',
+                    display: 'block',
+                    pointerEvents: 'none',
+                  }}
+                />
+              </button>
+
+              {/* 卡片 2、3 */}
+              {[0, 1].map((i) => (
+                <div
+                  key={`wenhao-${i}`}
+                  style={{
+                    width: px2vmin(90),
+                    height: px2vmin(90),
+                    borderRadius: px2vmin(16),
+                    boxSizing: 'border-box',
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    flexShrink: 0,
+                    overflow: 'hidden',
+                  }}
+                >
+                  <img
+                    src={wenhaoImg}
+                    alt=""
+                    draggable={false}
+                    style={{
+                      width: '100%',
+                      height: '100%',
+                      objectFit: 'contain',
+                      display: 'block',
+                    }}
+                  />
+                </div>
+              ))}
+                </>
+              )}
+            </div>
+          </div>
+        </div>
+        </div>
+      </div>
+    </>
+  );
+};

File diff ditekan karena terlalu besar
+ 808 - 428
frontend/src/pages/ProfilePage.tsx


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

@@ -18,6 +18,7 @@ import { LoginPage } from '@/pages/LoginPage';
 import { PhoneLogin } from '@/pages/PhoneLogin';
 import { LoginRegisterPage } from '@/pages/LoginRegisterPage';
 import { ProfilePage } from '@/pages/ProfilePage';
+import { LumiOutfitsPage } from '@/pages/LumiOutfitsPage';
 import { UserGuard } from '@/components/UserGuard';
 import { PageVisitReporter } from '@/components/PageVisitReporter';
 import { RootRedirect } from '@/pages/RootRedirect';
@@ -195,6 +196,10 @@ export const router = createBrowserRouter([
         path: '/profile',
         element: <ProfilePage />,
       },
+      {
+        path: '/lumi-outfits',
+        element: <LumiOutfitsPage />,
+      },
       {
         path: '*',
         element: <Navigate to="/" replace />,

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

@@ -363,6 +363,32 @@ export const getUserInfo = async (user_id: string): Promise<GetUserResponse> =>
   return data;
 };
 
+export interface JournalLatestTopicData {
+  scenario_key: string;
+  topic_key: string;
+  /** ISO 8601,个人页日记本场景图上的日期由此格式化 */
+  conversed_at: string;
+}
+
+export interface JournalLatestTopicResponse {
+  success: boolean;
+  data: JournalLatestTopicData | null;
+}
+
+export const getJournalLatestTopic = async (user_id: string): Promise<JournalLatestTopicResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/journal-latest-topic`, {
+    method: 'GET',
+    headers: getApiHeaders(),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
 /**
  * 更新用户名
  */
@@ -435,6 +461,32 @@ export const updateBotName = async (user_id: string, bot_name: string): Promise<
   return response.json();
 };
 
+/**
+ * 更新用户首选界面语言(zh/en)
+ */
+export interface UpdatePreferredLanguageResponse {
+  success: boolean;
+  message?: string;
+}
+
+export const updatePreferredLanguage = async (
+  user_id: string,
+  preferred_language: 'zh' | 'en',
+): Promise<UpdatePreferredLanguageResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/preferred-language`, {
+    method: 'PUT',
+    headers: getApiHeaders(),
+    body: JSON.stringify({ preferred_language }),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'UPDATE_PREFERRED_LANGUAGE_FAILED');
+  }
+
+  return response.json();
+};
+
 /**
  * 用户 UI 状态相关接口
  */

+ 39 - 0
frontend/src/utils/journalImages.ts

@@ -0,0 +1,39 @@
+import { getOssUrl } from '@/utils/oss';
+
+/** 与 backend/scenario_config.py 中 topic_key 一致 */
+const TOPIC_KEY_TO_OSS_FILE: Record<string, string> = {
+  weather: 'Weather.png',
+  season: 'Season.png',
+  travel: 'Travel.png',
+  reading: 'Reading.png',
+  hobbies: 'Hobbies.png',
+  relaxation: 'Relaxation.png',
+  favourite_food: 'FavoriteFood.png',
+  hometown_food: 'HometownFood.png',
+  daily_meal: 'DailyMeal.png',
+};
+
+export const journalMoreImageUrl = getOssUrl('lumi-journal-More.png');
+export const journalMoreEmojiUrl = getOssUrl('lumi-journal-More-emoji.png');
+
+/** 展示为 YY/MM/DD,例如 25/02/26(本地日历日) */
+export function formatJournalConversedAt(iso: string | null | undefined): string {
+  if (!iso || typeof iso !== 'string') return '';
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return '';
+  const yy = String(d.getFullYear()).slice(-2);
+  const mm = String(d.getMonth() + 1).padStart(2, '0');
+  const dd = String(d.getDate()).padStart(2, '0');
+  return `${yy}/${mm}/${dd}`;
+}
+
+export function getJournalLeftImageUrl(topicKey: string | null | undefined): string {
+  if (!topicKey || typeof topicKey !== 'string') {
+    return getOssUrl('lumi-journal-default.png');
+  }
+  const file = TOPIC_KEY_TO_OSS_FILE[topicKey.trim().toLowerCase()];
+  if (!file) {
+    return getOssUrl('lumi-journal-default.png');
+  }
+  return getOssUrl(file);
+}

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

@@ -34,6 +34,7 @@ const PATH_TO_PAGE_ID: Record<string, string> = {
   '/email-login': 'email_login',
   '/phone-login': 'phone_login',
   '/profile': 'profile',
+  '/lumi-outfits': 'lumi_outfits',
   '/error': 'error',
 };
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini