zlwu há 7 meses atrás
pai
commit
5d25e4749d

+ 6 - 0
backend/error_codes.py

@@ -58,5 +58,11 @@ WECHAT_MP_NOT_CONFIGURED = "WECHAT_MP_NOT_CONFIGURED"
 WECHAT_JSAPI_CONFIG_FAILED = "WECHAT_JSAPI_CONFIG_FAILED"
 GET_CONFIG_FAILED = "GET_CONFIG_FAILED"
 
+# Chatbot 商店
+INSUFFICIENT_CONVERSATION_ENERGY = "INSUFFICIENT_CONVERSATION_ENERGY"
+PURCHASE_LIMIT_REACHED = "PURCHASE_LIMIT_REACHED"
+SHOP_ITEM_NOT_FOUND = "SHOP_ITEM_NOT_FOUND"
+CART_QUANTITY_LIMIT_EXCEEDED = "CART_QUANTITY_LIMIT_EXCEEDED"
+
 # 兜底
 SERVER_ERROR = "SERVER_ERROR"

Diff do ficheiro suprimidas por serem muito extensas
+ 1073 - 62
backend/main.py


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

@@ -43,6 +43,10 @@
   "WECHAT_JSAPI_CONFIG_FAILED": "Failed to get WeChat JS-SDK config",
   "GET_CONFIG_FAILED": "Failed to get config",
   "SLOT_DEPENDENCY_NOT_SATISFIED": "Slot dependency is not satisfied. Place the required support item first",
+  "INSUFFICIENT_CONVERSATION_ENERGY": "Not enough conversation Energy Stones",
+  "PURCHASE_LIMIT_REACHED": "Purchase limit reached for this item",
+  "SHOP_ITEM_NOT_FOUND": "Item not found or no longer available",
+  "CART_QUANTITY_LIMIT_EXCEEDED": "The quantity in your cart plus items you already own exceeds the purchase limit for this product. Reduce the cart quantity and try again.",
   "SERVER_ERROR": "Something went wrong, please try again later",
   "UNKNOWN": "An error occurred, please try again"
 }

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
frontend/src/i18n/locales/en/profile.json


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

@@ -43,6 +43,10 @@
   "WECHAT_JSAPI_CONFIG_FAILED": "获取微信JS-SDK配置失败",
   "GET_CONFIG_FAILED": "获取配置失败",
   "SLOT_DEPENDENCY_NOT_SATISFIED": "当前槽位缺少前置依赖,请先摆放依赖槽位的支撑家具",
+  "INSUFFICIENT_CONVERSATION_ENERGY": "对话能量石不足",
+  "PURCHASE_LIMIT_REACHED": "已达到该商品的购买上限",
+  "SHOP_ITEM_NOT_FOUND": "商品不存在或已下架",
+  "CART_QUANTITY_LIMIT_EXCEEDED": "购物车数量与已购数量合计已超过该商品的可购上限,请减少购物车数量后再试",
   "SERVER_ERROR": "服务器异常,请稍后重试",
   "UNKNOWN": "发生错误,请重试"
 }

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
frontend/src/i18n/locales/zh/profile.json


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

@@ -14,8 +14,10 @@ import {
   getChatbotOwnedItems,
   type ChatbotLayoutBackdrop,
   type ChatbotLayoutItem,
-  type ChatbotBackdropSlot,
   getUserUIState,
+  getChatbotFurnitureEquipPreview,
+  type ChatbotBackdropSlot,
+  type ChatbotFurnitureEquipPreviewData,
   type ChatbotOwnedItem,
   unequipChatbotFurniture,
   updateUserUIState,
@@ -71,6 +73,54 @@ const CHATBOT_Z_INDEX = {
   GRID_LINES: 1,
   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;
+  /** 高于槽位描边层 (20) 与已放置家具,保证预览可见 */
+  const zIndex = Math.max(slot.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN, 20) + 10;
+  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)
+  );
+}
+
 const SLOT_HIGHLIGHT_COLORS = [
   '#00FF62',
   '#FFB020',
@@ -206,6 +256,9 @@ export const LumiOutfitsPage = () => {
   const [activeFurnitureModalItem, setActiveFurnitureModalItem] = useState<ChatbotOwnedItem | 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,
@@ -278,6 +331,37 @@ export const LumiOutfitsPage = () => {
     };
   }, [refreshChatbotLayout]);
 
+  useEffect(() => {
+    if (!activeFurnitureModalItem || furnitureModalMode !== 'equip') {
+      setFurnitureEquipPreview(null);
+      return;
+    }
+    const isBackdrop =
+      activeFurnitureModalItem.item_type === 'backdrop' ||
+      activeFurnitureModalItem.biz_type === 'wallpaper';
+    if (isBackdrop) {
+      setFurnitureEquipPreview(null);
+      return;
+    }
+    let cancelled = false;
+    void ensureSession()
+      .then(({ userId }) => getChatbotFurnitureEquipPreview(userId, activeFurnitureModalItem.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;
+    };
+  }, [activeFurnitureModalItem, furnitureModalMode]);
+
   const filteredOwnedFurnitureItems = useMemo(() => {
     if (furnitureFilter === 'all') return ownedFurnitureItems;
     return ownedFurnitureItems.filter((item) => item.biz_type === furnitureFilter);
@@ -285,6 +369,13 @@ export const LumiOutfitsPage = () => {
   const activeBackdropImageName =
     chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
   const activeLayoutItems = chatbotLayoutItems;
+  const activeLayoutItemsForRender = useMemo(() => {
+    if (!activeFurnitureModalItem || furnitureModalMode !== 'equip' || !furnitureEquipPreview) {
+      return activeLayoutItems;
+    }
+    const previewSlot = furnitureEquipPreview.slot;
+    return activeLayoutItems.filter((item) => !layoutItemOccupiesPreviewSlot(item, previewSlot));
+  }, [activeLayoutItems, activeFurnitureModalItem, furnitureModalMode, furnitureEquipPreview]);
   const cellWidth = chatbotGrid.cell_width ?? 96;
   const cellHeight = chatbotGrid.cell_height ?? 96;
   const gridRows = chatbotGrid.grid_rows ?? 5;
@@ -293,6 +384,17 @@ export const LumiOutfitsPage = () => {
   /** 5×4 在幕布层内水平居中,槽位相对网格左上角 */
   const gridOriginX = 0;
 
+  const furnitureEquipPreviewRect = useMemo(() => {
+    if (!furnitureEquipPreview) return null;
+    return computeChatbotSlotRectPx(
+      furnitureEquipPreview.slot,
+      gridOriginX,
+      gridOriginY,
+      cellWidth,
+      cellHeight,
+    );
+  }, [furnitureEquipPreview, cellWidth, cellHeight]);
+
   const handleSelectFurnitureTab = useCallback(() => {
     setLumiBagTab('furniture');
   }, []);
@@ -518,7 +620,7 @@ export const LumiOutfitsPage = () => {
               </div>
             )}
 
-            {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;
@@ -581,6 +683,45 @@ export const LumiOutfitsPage = () => {
                   />
                 );
               })}
+
+            {activeFurnitureModalItem &&
+            furnitureModalMode === 'equip' &&
+            furnitureEquipPreview &&
+            furnitureEquipPreviewRect ? (
+              <>
+                <div
+                  className="absolute pointer-events-none"
+                  style={{
+                    left: px2vmin(furnitureEquipPreviewRect.leftPx),
+                    top: px2vmin(furnitureEquipPreviewRect.topPx),
+                    width: px2vmin(furnitureEquipPreviewRect.widthPx),
+                    height: px2vmin(furnitureEquipPreviewRect.heightPx),
+                    border: '2PX dashed rgba(255, 255, 255, 0.7)',
+                    boxSizing: 'border-box',
+                    zIndex: furnitureEquipPreviewRect.zIndex + 1,
+                    borderRadius: px2vmin(6),
+                  }}
+                  aria-hidden
+                />
+                <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>
 

Diff do ficheiro suprimidas por serem muito extensas
+ 898 - 34
frontend/src/pages/LumiShopPage.tsx


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

@@ -1152,6 +1152,8 @@ export interface ChatbotLayoutBackdrop {
 
 export interface ChatbotLayoutItem {
   id: number;
+  /** 槽位系统下与 chatbot_backdrop_slots.slot_code 一致,用于前端预览时隐藏同槽旧物 */
+  slot_code?: string;
   placement_type: ChatbotLayoutPlacementType;
   start_row: number;
   start_col: number;
@@ -1213,6 +1215,38 @@ export const getChatbotLayout = async (user_id: string): Promise<GetChatbotLayou
   return response.json();
 };
 
+/** 与 POST equip 同一槽位解析,用于装备确认弹窗内的网格预览 */
+export interface ChatbotFurnitureEquipPreviewData {
+  slot: ChatbotBackdropSlot;
+  preview_image_name: string;
+}
+
+export interface GetChatbotFurnitureEquipPreviewResponse {
+  success: boolean;
+  data: ChatbotFurnitureEquipPreviewData | null;
+}
+
+export const getChatbotFurnitureEquipPreview = async (
+  user_id: string,
+  item_id: number,
+): Promise<GetChatbotFurnitureEquipPreviewResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const params = new URLSearchParams({ item_id: String(item_id) });
+  const response = await fetch(
+    `${apiBaseUrl}/api/users/${user_id}/chatbot-furniture/equip-preview?${params.toString()}`,
+    {
+      method: 'GET',
+      headers: getApiHeaders(),
+    },
+  );
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
 export type ShopItemBizType =
   | 'seat_sofa'
   | 'table_cabinet'
@@ -1239,6 +1273,11 @@ export interface ChatbotShopCatalogItem extends ChatbotOwnedItem {
   is_owned: boolean;
   /** 能量石售价(来自 shop_items.price_energy) */
   price_energy?: number;
+  /** 每用户可购上限;null 表示不限制 */
+  max_per_user?: number | null;
+  /** 用户当前拥有数量(user_assets.owned_qty) */
+  owned_qty?: number;
+  repeat_purchase_mode?: 'single' | 'repeatable';
 }
 
 export interface GetChatbotOwnedItemsResponse {
@@ -1279,6 +1318,140 @@ export const getChatbotShopCatalog = async (user_id: string): Promise<GetChatbot
   return response.json();
 };
 
+export interface ChatbotShopCartLine {
+  item_id: number;
+  quantity: number;
+  unit_price_energy: number;
+  unit_price_crystal: number;
+}
+
+export interface GetChatbotShopCartResponse {
+  success: boolean;
+  data: {
+    items: ChatbotShopCartLine[];
+    cart_total_quantity: number;
+  };
+}
+
+export const getChatbotShopCart = async (user_id: string): Promise<GetChatbotShopCartResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-cart`, {
+    method: 'GET',
+    headers: getApiHeaders(),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
+export interface AddChatbotShopCartItemResponse {
+  success: boolean;
+  data: {
+    item_id: number;
+    item_quantity: number;
+    cart_total_quantity: number;
+  };
+}
+
+export const addChatbotShopCartItem = async (
+  user_id: string,
+  item_id: number,
+): Promise<AddChatbotShopCartItemResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-cart/add`, {
+    method: 'POST',
+    headers: getApiHeaders(),
+    body: JSON.stringify({ item_id }),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
+export interface RemoveChatbotShopCartItemResponse {
+  success: boolean;
+  data: {
+    item_id: number;
+    cart_total_quantity: number;
+  };
+}
+
+export const removeChatbotShopCartItem = async (
+  user_id: string,
+  item_id: number,
+): Promise<RemoveChatbotShopCartItemResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-cart/remove`, {
+    method: 'POST',
+    headers: getApiHeaders(),
+    body: JSON.stringify({ item_id }),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
+export interface PurchaseChatbotShopCartResponse {
+  success: boolean;
+  data: {
+    total_quantity: number;
+    total_cost_energy: number;
+  };
+}
+
+export const purchaseChatbotShopCartItems = async (
+  user_id: string,
+): Promise<PurchaseChatbotShopCartResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-cart/purchase`, {
+    method: 'POST',
+    headers: getApiHeaders(),
+    body: JSON.stringify({}),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
+export interface PurchaseChatbotShopItemResponse {
+  success: boolean;
+  data: {
+    item_id: number;
+    cost_energy: number;
+    order_id?: number;
+  };
+}
+
+export const purchaseChatbotShopItem = async (
+  user_id: string,
+  item_id: number,
+): Promise<PurchaseChatbotShopItemResponse> => {
+  const apiBaseUrl = getApiBaseUrl();
+  const response = await fetch(`${apiBaseUrl}/api/users/${user_id}/chatbot-shop-purchase`, {
+    method: 'POST',
+    headers: getApiHeaders(),
+    body: JSON.stringify({ item_id }),
+  });
+
+  if (!response.ok) {
+    throw await parseApiError(response, 'SERVER_ERROR');
+  }
+
+  return response.json();
+};
+
 export interface UpdateChatbotBackdropResponse {
   success: boolean;
   data: {

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff