zlwu 7 месяцев назад
Родитель
Сommit
6836b9508d
2 измененных файлов с 95 добавлено и 69 удалено
  1. 1 1
      frontend/src/pages/LumiOutfitsPage.tsx
  2. 94 68
      frontend/src/pages/LumiShopPage.tsx

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

@@ -192,7 +192,7 @@ export const LumiOutfitsPage = () => {
   }, [currentAvatarId]);
 
   const handleBack = useCallback(() => {
-    navigate(-1);
+    navigate('/chatbot');
   }, [navigate]);
 
   const onBackClick = useMemo(

+ 94 - 68
frontend/src/pages/LumiShopPage.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
@@ -58,6 +58,14 @@ function floorConversationEnergyPrice(item: ChatbotShopCatalogItem): number {
   return Math.floor(pe);
 }
 
+/** 判断商品是否已购满(购买数量已达上限) */
+function isPurchaseLimitReached(item: ChatbotShopCatalogItem): boolean {
+  const max = item.max_per_user;
+  const owned = item.owned_qty ?? 0;
+  if (max == null) return false;
+  return owned >= max;
+}
+
 type FurnitureFilterKey =
   | 'all'
   | 'table_cabinet'
@@ -138,19 +146,8 @@ function layoutItemOccupiesPreviewSlot(
 
 type FurnitureActionModalMode = 'equip' | 'remove';
 
-/** 仅允许应用内相对路径,避免 open redirect */
-function parseShopReturnPath(state: unknown): string | null {
-  if (!state || typeof state !== 'object') return null;
-  const from = (state as { from?: unknown }).from;
-  if (typeof from !== 'string' || from.length === 0 || from.length > 512) return null;
-  if (!from.startsWith('/') || from.startsWith('//')) return null;
-  if (from.includes('://')) return null;
-  return from.split('#')[0] || null;
-}
-
 export const LumiShopPage = () => {
   const navigate = useNavigate();
-  const location = useLocation();
   const [searchParams] = useSearchParams();
   const { t, i18n } = useTranslation('common');
   const { t: tProfile } = useTranslation('profile');
@@ -164,13 +161,8 @@ export const LumiShopPage = () => {
   }, []);
 
   const handleBack = useCallback(() => {
-    const target = parseShopReturnPath(location.state);
-    if (target) {
-      navigate(target);
-      return;
-    }
-    navigate(-1);
-  }, [navigate, location.state]);
+    navigate('/chatbot');
+  }, [navigate]);
 
   const onBackClick = useMemo(
     () => wrapTrackedClick('lumi_shop', 'back', handleBack),
@@ -341,19 +333,19 @@ export const LumiShopPage = () => {
   }, [refreshChatbotLayout]);
 
   useEffect(() => {
-    if (!activePurchaseItem) {
+    const item = activePurchaseItem || activeFurnitureModalItem;
+    if (!item) {
       setFurnitureEquipPreview(null);
       return;
     }
-    const isBackdrop =
-      activePurchaseItem.item_type === 'backdrop' || activePurchaseItem.biz_type === 'wallpaper';
+    const isBackdrop = item.item_type === 'backdrop' || item.biz_type === 'wallpaper';
     if (isBackdrop) {
       setFurnitureEquipPreview(null);
       return;
     }
     let cancelled = false;
     void ensureSession()
-      .then(({ userId }) => getChatbotFurnitureEquipPreview(userId, activePurchaseItem.item_id))
+      .then(({ userId }) => getChatbotFurnitureEquipPreview(userId, item.item_id))
       .then((res) => {
         if (cancelled) return;
         if (res.success && res.data) {
@@ -368,22 +360,35 @@ export const LumiShopPage = () => {
     return () => {
       cancelled = true;
     };
-  }, [activePurchaseItem]);
+  }, [activePurchaseItem, activeFurnitureModalItem]);
 
   const filteredFurnitureCatalogItems = useMemo(() => {
     const paidOnly = furnitureCatalogItems.filter((item) => floorConversationEnergyPrice(item) > 0);
-    if (furnitureFilter === 'all') return paidOnly;
-    return paidOnly.filter((item) => item.biz_type === furnitureFilter);
+    let filtered = furnitureFilter === 'all' ? paidOnly : paidOnly.filter((item) => item.biz_type === furnitureFilter);
+
+    // 排序规则:
+    // 1. 可再次购买的商品(未购满)排在前面
+    // 2. 已购满的商品(无法再购买)排在后面
+    return filtered.sort((a, b) => {
+      const aMaxed = isPurchaseLimitReached(a);
+      const bMaxed = isPurchaseLimitReached(b);
+      if (aMaxed && !bMaxed) return 1;
+      if (!aMaxed && bMaxed) return -1;
+      return 0;
+    });
   }, [furnitureFilter, furnitureCatalogItems]);
   const activeBackdropImageName =
     chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
   const activeLayoutItems = chatbotLayoutItems;
   const activeLayoutItemsForRender = useMemo(() => {
-    if (!activePurchaseItem || !furnitureEquipPreview) {
+    const preview =
+      furnitureEquipPreview &&
+      (activePurchaseItem || (activeFurnitureModalItem && furnitureModalMode === 'equip'));
+    if (!preview) {
       return activeLayoutItems;
     }
     return activeLayoutItems.filter((item) => !layoutItemOccupiesPreviewSlot(item, furnitureEquipPreview.slot));
-  }, [activeLayoutItems, activePurchaseItem, furnitureEquipPreview]);
+  }, [activeLayoutItems, activePurchaseItem, activeFurnitureModalItem, furnitureModalMode, furnitureEquipPreview]);
   const cellWidth = chatbotGrid.cell_width ?? 96;
   const cellHeight = chatbotGrid.cell_height ?? 96;
   const gridCols = chatbotGrid.grid_cols ?? 4;
@@ -654,17 +659,10 @@ export const LumiShopPage = () => {
         setActivePurchaseItem(item);
         return;
       }
-      if (repeatable && !buyMore) {
-        showFurnitureNotice(tProfile('lumiShopPurchaseLimitReached'));
-        return;
-      }
 
-      /**
-       * shop_items.max_per_user 有值时:实际拥有数 owned_qty ≥ 上限则无法再购,
-       * 仅提示(含幕布/墙纸),不打开装备弹窗或切换幕布。max 为 null 表示不限购。
-       */
-      if (item.is_owned && max != null && owned >= max) {
-        showFurnitureNotice(tProfile('lumiShopLimitedOwnedHint'));
+      // 墙纸/幕布特殊处理:已装备则提示,未装备则直接切换
+      if (isBackdrop && item.is_in_use) {
+        showFurnitureNotice(tProfile('lumiWallpaperFixedNotice'));
         return;
       }
 
@@ -678,6 +676,7 @@ export const LumiShopPage = () => {
         return;
       }
 
+      // 其他已拥有商品:打开装备/卸下弹窗
       setActiveFurnitureModalItem(item);
       setFurnitureModalMode(item.is_in_use ? 'remove' : 'equip');
     },
@@ -850,7 +849,9 @@ export const LumiShopPage = () => {
               />
             );
           })}
-          {activePurchaseItem && furnitureEquipPreview && furnitureEquipPreviewRect ? (
+          {((activePurchaseItem || (activeFurnitureModalItem && furnitureModalMode === 'equip')) &&
+          furnitureEquipPreview &&
+          furnitureEquipPreviewRect) ? (
             <img
               src={getOssUrl(furnitureEquipPreview.preview_image_name)}
               alt=""
@@ -1451,40 +1452,65 @@ export const LumiShopPage = () => {
                           pointerEvents: 'none',
                         }}
                       />
-                      <div
-                        style={{
-                          position: 'relative',
-                          width: px2vmin(70),
-                          height: px2vmin(20),
-                          flexShrink: 0,
-                          pointerEvents: 'none',
-                        }}
-                      >
-                        <img
-                          src={conversationEnergyStoneCount}
-                          alt=""
-                          draggable={false}
+                      {item.is_owned && isPurchaseLimitReached(item) ? (
+                        <div
                           style={{
-                            width: '100%',
-                            height: '100%',
-                            objectFit: 'contain',
-                            display: 'block',
+                            width: px2vmin(50),
+                            height: px2vmin(20),
+                            backgroundColor: 'rgba(0, 0, 0, 0.2)',
+                            borderRadius: px2vmin(20),
+                            display: 'flex',
+                            alignItems: 'center',
+                            justifyContent: 'center',
+                            flexShrink: 0,
                           }}
-                        />
-                        <span
+                        >
+                          <span
+                            style={{
+                              fontSize: px2vmin(12),
+                              color: '#fff',
+                              fontWeight: 400,
+                            }}
+                          >
+                            {isZh ? '已拥有' : 'Owned'}
+                          </span>
+                        </div>
+                      ) : (
+                        <div
                           style={{
-                            position: 'absolute',
-                            top: '50%',
-                            left: '60%',
-                            transform: 'translate(-50%, -50%)',
-                            fontSize: px2vmin(10),
-                            color: '#000',
-                            fontWeight: 500,
+                            position: 'relative',
+                            width: px2vmin(70),
+                            height: px2vmin(20),
+                            flexShrink: 0,
+                            pointerEvents: 'none',
                           }}
                         >
-                          {formatCatalogEnergyCount(item)}
-                        </span>
-                      </div>
+                          <img
+                            src={conversationEnergyStoneCount}
+                            alt=""
+                            draggable={false}
+                            style={{
+                              width: '100%',
+                              height: '100%',
+                              objectFit: 'contain',
+                              display: 'block',
+                            }}
+                          />
+                          <span
+                            style={{
+                              position: 'absolute',
+                              top: '50%',
+                              left: '60%',
+                              transform: 'translate(-50%, -50%)',
+                              fontSize: px2vmin(10),
+                              color: '#000',
+                              fontWeight: 500,
+                            }}
+                          >
+                            {formatCatalogEnergyCount(item)}
+                          </span>
+                        </div>
+                      )}
                     </button>
                   ))}
                 </div>