zlwu 7 месяцев назад
Родитель
Сommit
7c4fa1e645

+ 3 - 3
backend/.env.lyrria

@@ -68,11 +68,11 @@ STT_LANGUAGE=zh
 # TTS 连接健康检查间隔(秒)
 #TTS_HEALTHCHECK_INTERVAL=300
 TTS_MODEL=cosyvoice-v3-flash
-TTS_VOICE=longanhuan
+TTS_VOICE=longhua_v3 #longanhuan
 
 # 语速/音调/语言提示
-TTS_SPEECH_RATE=0.7
-TTS_PITCH_RATE=1.15
+TTS_SPEECH_RATE=0.9 #0.7
+TTS_PITCH_RATE=1 #1.15
 TTS_LANGUAGE_HINTS=en
 
 # AI 助手提示词配置

+ 106 - 20
backend/main.py

@@ -2325,6 +2325,11 @@ async def update_chatbot_backdrop(
                 if await _get_user_id_is_bigint("user_chatbot_backdrops")
                 else ":user_id"
             )
+            placement_user_id_expr = (
+                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
+                if await _get_user_id_is_bigint("user_chatbot_slot_placements")
+                else ":user_id"
+            )
             assets_user_id_expr = (
                 "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
                 if await _get_user_id_is_bigint("user_assets")
@@ -2356,6 +2361,26 @@ async def update_chatbot_backdrop(
             if str(owned_row.get("item_type") or "") != "backdrop":
                 raise HTTPException(status_code=400, detail={"code": "ITEM_NOT_BACKDROP"})
 
+            target_backdrop_item_id = int(request.backdrop_item_id)
+            current_backdrop_res = await session.execute(
+                text(
+                    f"""
+                    SELECT backdrop_item_id
+                    FROM user_chatbot_backdrops
+                    WHERE user_id = {backdrop_user_id_expr}
+                    ORDER BY updated_at DESC, id DESC
+                    LIMIT 1
+                    """
+                ),
+                {"user_id": user_id},
+            )
+            current_backdrop_row = current_backdrop_res.mappings().first()
+            source_backdrop_item_id = (
+                int(current_backdrop_row["backdrop_item_id"])
+                if current_backdrop_row and current_backdrop_row.get("backdrop_item_id") is not None
+                else None
+            )
+
             await session.execute(
                 text(
                     f"""
@@ -2376,15 +2401,81 @@ async def update_chatbot_backdrop(
                 ),
                 {
                     "user_id": user_id,
-                    "backdrop_item_id": int(request.backdrop_item_id),
+                    "backdrop_item_id": target_backdrop_item_id,
                     "z_index": int(owned_row.get("default_z_index") or 0),
                 },
             )
 
+            slot_table_exists_res = await session.execute(
+                text(
+                    """
+                    SELECT COUNT(*) AS c
+                    FROM information_schema.tables
+                    WHERE table_schema = :db
+                      AND table_name = 'user_chatbot_slot_placements'
+                    """
+                ),
+                {"db": settings.MYSQL_DATABASE},
+            )
+            slot_table_exists = int((slot_table_exists_res.mappings().first() or {}).get("c") or 0) > 0
+
+            # 幕布切换时:先清空目标幕布已有摆放,再尝试按槽位复制来源幕布的有效摆放。
+            if slot_table_exists:
+                await session.execute(
+                    text(
+                        f"""
+                        DELETE FROM user_chatbot_slot_placements
+                        WHERE user_id = {placement_user_id_expr}
+                          AND backdrop_item_id = :target_backdrop_item_id
+                        """
+                    ),
+                    {"user_id": user_id, "target_backdrop_item_id": target_backdrop_item_id},
+                )
+
+                if source_backdrop_item_id is not None and source_backdrop_item_id != target_backdrop_item_id:
+                    await session.execute(
+                        text(
+                            f"""
+                            INSERT INTO user_chatbot_slot_placements
+                              (user_id, backdrop_item_id, slot_code, user_asset_id, item_id, z_index,
+                               image_name_snapshot, placed_at, updated_at)
+                            SELECT
+                              src.user_id,
+                              :target_backdrop_item_id AS backdrop_item_id,
+                              src.slot_code,
+                              src.user_asset_id,
+                              src.item_id,
+                              src.z_index,
+                              src.image_name_snapshot,
+                              NOW(),
+                              NOW()
+                            FROM user_chatbot_slot_placements src
+                            JOIN chatbot_backdrop_slots target_slot
+                              ON target_slot.backdrop_item_id = :target_backdrop_item_id
+                             AND target_slot.slot_code = src.slot_code
+                             AND target_slot.is_active = 1
+                            WHERE src.user_id = {placement_user_id_expr}
+                              AND src.backdrop_item_id = :source_backdrop_item_id
+                            ON DUPLICATE KEY UPDATE
+                              user_asset_id = VALUES(user_asset_id),
+                              item_id = VALUES(item_id),
+                              z_index = VALUES(z_index),
+                              image_name_snapshot = VALUES(image_name_snapshot),
+                              placed_at = VALUES(placed_at),
+                              updated_at = NOW()
+                            """
+                        ),
+                        {
+                            "user_id": user_id,
+                            "source_backdrop_item_id": source_backdrop_item_id,
+                            "target_backdrop_item_id": target_backdrop_item_id,
+                        },
+                    )
+
             await session.commit()
             return {
                 "success": True,
-                "data": {"backdrop_item_id": int(request.backdrop_item_id)},
+                "data": {"backdrop_item_id": target_backdrop_item_id},
             }
     except HTTPException:
         raise
@@ -2421,38 +2512,31 @@ async def get_chatbot_furniture_equip_preview(
                 row = res.mappings().first()
                 return "bigint" in str((row or {}).get("DATA_TYPE") or "").lower()
 
-            assets_user_id_expr = (
-                "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
-                if await _get_user_id_is_bigint("user_assets")
-                else ":user_id"
-            )
             backdrop_user_id_expr = (
                 "(SELECT id FROM users WHERE user_id = :user_id LIMIT 1)"
                 if await _get_user_id_is_bigint("user_chatbot_backdrops")
                 else ":user_id"
             )
 
-            owned_res = await session.execute(
+            item_res = await session.execute(
                 text(
-                    f"""
+                    """
                     SELECT
                         si.id AS item_id,
                         si.item_type,
                         si.image_name,
-                        si.render_image_name
-                    FROM user_assets ua
-                    JOIN shop_items si ON si.id = ua.item_id
-                    WHERE ua.user_id = {assets_user_id_expr}
-                      AND ua.item_id = :item_id
-                      AND ua.owned_qty > 0
+                        si.render_image_name,
+                        COALESCE(si.default_z_index, 0) AS default_z_index
+                    FROM shop_items si
+                    WHERE si.id = :item_id
                       AND si.is_active = 1
                     LIMIT 1
                     """
                 ),
-                {"user_id": user_id, "item_id": item_id},
+                {"item_id": item_id},
             )
-            owned_row = owned_res.mappings().first()
-            if not owned_row or str(owned_row.get("item_type") or "") != "furniture":
+            item_row = item_res.mappings().first()
+            if not item_row or str(item_row.get("item_type") or "") != "furniture":
                 return {"success": True, "data": None}
 
             rules_table_res = await session.execute(
@@ -2541,7 +2625,8 @@ async def get_chatbot_furniture_equip_preview(
             if not slot_row:
                 return {"success": True, "data": None}
 
-            preview_image_name = scene_image_name_from_shop_row(dict(owned_row))
+            preview_image_name = scene_image_name_from_shop_row(dict(item_row))
+            item_z_index = int(item_row.get("default_z_index") or 0)
             slot_payload = {
                 "id": int(slot_row["id"]),
                 "slot_code": str(slot_row.get("slot_code") or ""),
@@ -2550,7 +2635,8 @@ async def get_chatbot_furniture_equip_preview(
                 "start_col": int(slot_row.get("start_col") or 1),
                 "size_rows": int(slot_row.get("size_rows") or 1),
                 "size_cols": int(slot_row.get("size_cols") or 1),
-                "z_index": int(slot_row.get("z_index") or 0),
+                # 与 POST /chatbot-furniture/equip 保持一致:渲染层级取商品 default_z_index
+                "z_index": item_z_index,
                 "render_mode": slot_row.get("render_mode"),
                 "offset_x": slot_row.get("offset_x"),
                 "offset_y": slot_row.get("offset_y"),

+ 6 - 2
frontend/src/pages/LumiOutfitsPage.tsx

@@ -93,8 +93,12 @@ function computeChatbotSlotRectPx(
   const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
   const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
   const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
-  /** 高于槽位描边层 (20) 与已放置家具,保证预览可见 */
-  const zIndex = Math.max(slot.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN, 20) + 10;
+  /**
+   * 预览层级与实际已摆放家具保持一致,避免跨槽位错误遮挡:
+   * - 同槽位已摆放家具会在渲染时被过滤掉;
+   * - 其他槽位按数据库 z_index 正常前后关系显示。
+   */
+  const zIndex = Math.max(slot.z_index ?? 0, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN) + 1;
   return { leftPx, topPx, widthPx, heightPx, zIndex };
 }
 

+ 123 - 1
frontend/src/pages/LumiShopPage.tsx

@@ -9,6 +9,7 @@ import {
   ApiError,
   ensureSession,
   equipChatbotFurniture,
+  getChatbotFurnitureEquipPreview,
   getChatbotLayout,
   getUserInfo,
   getChatbotShopCatalog,
@@ -17,6 +18,8 @@ import {
   removeChatbotShopCartItem,
   purchaseChatbotShopCartItems,
   purchaseChatbotShopItem,
+  type ChatbotBackdropSlot,
+  type ChatbotFurnitureEquipPreviewData,
   type ChatbotLayoutBackdrop,
   type ChatbotLayoutItem,
   type ChatbotShopCatalogItem,
@@ -82,6 +85,57 @@ const CHATBOT_Z_INDEX = {
   GRID_DYNAMIC_MIN: 3,
 } as const;
 
+/** 与网格内已放置家具同一套坐标算法,用于装备预览层 */
+function computeChatbotSlotRectPx(
+  slot: ChatbotBackdropSlot,
+  gridOriginX: number,
+  gridOriginY: number,
+  cellWidth: number,
+  cellHeight: number,
+): { leftPx: number; topPx: number; widthPx: number; heightPx: number; zIndex: number } {
+  const slotLeftPx = gridOriginX + (slot.start_col - 1) * cellWidth;
+  const slotTopPx = gridOriginY + (slot.start_row - 1) * cellHeight;
+  const slotWidthPx = slot.size_cols * cellWidth;
+  const slotHeightPx = slot.size_rows * cellHeight;
+  const isPixelSlot = slot.render_mode === 'pixel';
+  const offsetX = slot.offset_x ?? 0;
+  const offsetY = slot.offset_y ?? 0;
+  const widthPx = isPixelSlot ? (slot.pixel_width ?? slotWidthPx) : slotWidthPx;
+  const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
+  const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
+  const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
+  /**
+   * 预览层级与实际已摆放家具保持一致,避免跨槽位错误遮挡:
+   * - 同槽位已摆放家具会在渲染时被过滤掉;
+   * - 其他槽位按数据库 z_index 正常前后关系显示。
+   */
+  const zIndex = Math.max(slot.z_index ?? 0, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN) + 1;
+  return { leftPx, topPx, widthPx, heightPx, zIndex };
+}
+
+/** 装备预览时隐藏该槽位上已有家具:优先 slot_code,无则按槽位几何比对(旧布局) */
+function layoutItemOccupiesPreviewSlot(
+  item: ChatbotLayoutItem,
+  previewSlot: ChatbotBackdropSlot,
+): boolean {
+  const code = (previewSlot.slot_code || '').trim();
+  const itemCode = (item.slot_code || '').trim();
+  if (itemCode && code) {
+    return itemCode === code;
+  }
+  return (
+    item.start_row === previewSlot.start_row &&
+    item.start_col === previewSlot.start_col &&
+    item.size_rows === previewSlot.size_rows &&
+    item.size_cols === previewSlot.size_cols &&
+    (item.render_mode ?? 'grid') === (previewSlot.render_mode ?? 'grid') &&
+    (item.offset_x ?? 0) === (previewSlot.offset_x ?? 0) &&
+    (item.offset_y ?? 0) === (previewSlot.offset_y ?? 0) &&
+    (item.pixel_width ?? null) === (previewSlot.pixel_width ?? null) &&
+    (item.pixel_height ?? null) === (previewSlot.pixel_height ?? null)
+  );
+}
+
 type FurnitureActionModalMode = 'equip' | 'remove';
 
 /** 仅允许应用内相对路径,避免 open redirect */
@@ -185,6 +239,9 @@ export const LumiShopPage = () => {
   const [activeFurnitureModalItem, setActiveFurnitureModalItem] = useState<ChatbotShopCatalogItem | null>(null);
   const [furnitureModalMode, setFurnitureModalMode] = useState<FurnitureActionModalMode>('equip');
   const [furnitureSubmitting, setFurnitureSubmitting] = useState<boolean>(false);
+  const [furnitureEquipPreview, setFurnitureEquipPreview] = useState<ChatbotFurnitureEquipPreviewData | null>(
+    null,
+  );
   const [chatbotGrid, setChatbotGrid] = useState(() => ({
     grid_rows: 5,
     grid_cols: 4,
@@ -283,6 +340,36 @@ export const LumiShopPage = () => {
     };
   }, [refreshChatbotLayout]);
 
+  useEffect(() => {
+    if (!activePurchaseItem) {
+      setFurnitureEquipPreview(null);
+      return;
+    }
+    const isBackdrop =
+      activePurchaseItem.item_type === 'backdrop' || activePurchaseItem.biz_type === 'wallpaper';
+    if (isBackdrop) {
+      setFurnitureEquipPreview(null);
+      return;
+    }
+    let cancelled = false;
+    void ensureSession()
+      .then(({ userId }) => getChatbotFurnitureEquipPreview(userId, activePurchaseItem.item_id))
+      .then((res) => {
+        if (cancelled) return;
+        if (res.success && res.data) {
+          setFurnitureEquipPreview(res.data);
+        } else {
+          setFurnitureEquipPreview(null);
+        }
+      })
+      .catch(() => {
+        if (!cancelled) setFurnitureEquipPreview(null);
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [activePurchaseItem]);
+
   const filteredFurnitureCatalogItems = useMemo(() => {
     const paidOnly = furnitureCatalogItems.filter((item) => floorConversationEnergyPrice(item) > 0);
     if (furnitureFilter === 'all') return paidOnly;
@@ -291,12 +378,28 @@ export const LumiShopPage = () => {
   const activeBackdropImageName =
     chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
   const activeLayoutItems = chatbotLayoutItems;
+  const activeLayoutItemsForRender = useMemo(() => {
+    if (!activePurchaseItem || !furnitureEquipPreview) {
+      return activeLayoutItems;
+    }
+    return activeLayoutItems.filter((item) => !layoutItemOccupiesPreviewSlot(item, furnitureEquipPreview.slot));
+  }, [activeLayoutItems, activePurchaseItem, furnitureEquipPreview]);
   const cellWidth = chatbotGrid.cell_width ?? 96;
   const cellHeight = chatbotGrid.cell_height ?? 96;
   const gridCols = chatbotGrid.grid_cols ?? 4;
   const gridOriginY = 0;
   /** 5×4 区整体在幕布层水平居中,槽位坐标相对网格左上角 */
   const gridOriginX = 0;
+  const furnitureEquipPreviewRect = useMemo(() => {
+    if (!furnitureEquipPreview) return null;
+    return computeChatbotSlotRectPx(
+      furnitureEquipPreview.slot,
+      gridOriginX,
+      gridOriginY,
+      cellWidth,
+      cellHeight,
+    );
+  }, [furnitureEquipPreview, gridOriginX, gridOriginY, cellWidth, cellHeight]);
 
   const handleSelectFurnitureTab = useCallback(() => {
     setLumiBagTab('furniture');
@@ -714,7 +817,7 @@ export const LumiShopPage = () => {
               zIndex: 1,
             }}
           >
-          {activeLayoutItems.map((item, idx) => {
+          {activeLayoutItemsForRender.map((item, idx) => {
             const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
             const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
             const slotWidthPx = item.size_cols * cellWidth;
@@ -747,6 +850,25 @@ export const LumiShopPage = () => {
               />
             );
           })}
+          {activePurchaseItem && furnitureEquipPreview && furnitureEquipPreviewRect ? (
+            <img
+              src={getOssUrl(furnitureEquipPreview.preview_image_name)}
+              alt=""
+              className="absolute pointer-events-none"
+              fetchPriority="low"
+              loading="eager"
+              draggable={false}
+              style={{
+                width: px2vmin(furnitureEquipPreviewRect.widthPx),
+                height: px2vmin(furnitureEquipPreviewRect.heightPx),
+                left: px2vmin(furnitureEquipPreviewRect.leftPx),
+                top: px2vmin(furnitureEquipPreviewRect.topPx),
+                zIndex: furnitureEquipPreviewRect.zIndex,
+                objectFit: 'contain',
+                opacity: 0.88,
+              }}
+            />
+          ) : null}
           </div>
         </div>
 

+ 48 - 4
frontend/src/pages/ProfilePage.tsx

@@ -639,12 +639,18 @@ export const ProfilePage = () => {
         style={{
           position: 'absolute',
           top: px2vmin(241),
-          left: '50%',
-          transform: 'translateX(-50%)',
+          left: 0,
+          width: '100%',
           height: px2vmin(52),
           display: 'flex',
           alignItems: 'center',
           justifyContent: 'center',
+          overflowX: 'auto',
+          overflowY: 'hidden',
+          paddingLeft: px2vmin(12),
+          paddingRight: px2vmin(12),
+          boxSizing: 'border-box',
+          WebkitOverflowScrolling: 'touch',
           zIndex: 120,
         }}
       >
@@ -653,8 +659,11 @@ export const ProfilePage = () => {
             display: 'flex',
             flexDirection: 'row',
             alignItems: 'center',
+            justifyContent: 'center',
             gap: px2vmin(8),
             maxHeight: '100%',
+            minWidth: '100%',
+            width: 'max-content',
           }}
         >
           <span
@@ -665,6 +674,8 @@ export const ProfilePage = () => {
               display: 'inline-block',
               color: '#fff',
               fontWeight: 500,
+              whiteSpace: 'nowrap',
+              flexShrink: 0,
             }}
           >
             {username || tCommon('userDefault')}
@@ -1173,6 +1184,28 @@ export const ProfilePage = () => {
                     display: 'block',
                   }}
                 />
+                <span
+                  style={{
+                    position: 'absolute',
+                    top: px2vmin(10),
+                    left: px2vmin(10),
+                    width: px2vmin(39),
+                    height: px2vmin(20),
+                    borderRadius: px2vmin(22),
+                    backgroundColor: '#FFDC99',
+                    color: '#000000',
+                    fontSize: px2vmin(11),
+                    lineHeight: px2vmin(11),
+                    fontWeight: 500,
+                    display: 'flex',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    textAlign: 'center',
+                    pointerEvents: 'none',
+                  }}
+                >
+                  {i18n.language.startsWith('zh') ? '新的' : 'New'}
+                </span>
                 {journalConversedAtLabel ? (
                   <span
                     style={{
@@ -1396,12 +1429,18 @@ export const ProfilePage = () => {
             style={{
               position: 'absolute',
               top: px2vmin(241),
-              left: '50%',
-              transform: 'translateX(-50%)',
+              left: 0,
+              width: '100%',
               height: px2vmin(52),
               display: 'flex',
               alignItems: 'center',
               justifyContent: 'center',
+              overflowX: 'auto',
+              overflowY: 'hidden',
+              paddingLeft: px2vmin(12),
+              paddingRight: px2vmin(12),
+              boxSizing: 'border-box',
+              WebkitOverflowScrolling: 'touch',
               zIndex: 120,
             }}
           >
@@ -1410,8 +1449,11 @@ export const ProfilePage = () => {
                 display: 'flex',
                 flexDirection: 'row',
                 alignItems: 'center',
+                justifyContent: 'center',
                 gap: px2vmin(8),
                 maxHeight: '100%',
+                minWidth: '100%',
+                width: 'max-content',
               }}
             >
               <span
@@ -1422,6 +1464,8 @@ export const ProfilePage = () => {
                   display: 'inline-block',
                   color: '#fff',
                   fontWeight: 500,
+                  whiteSpace: 'nowrap',
+                  flexShrink: 0,
                 }}
               >
                 {botName || 'Lumi'}