Ver Fonte

首页列表优化

sunshibo há 4 semanas atrás
pai
commit
380d54e3d7
3 ficheiros alterados com 153 adições e 67 exclusões
  1. 4 0
      api/dining.js
  2. 12 3
      pages/orderFood/components/FoodCard.vue
  3. 137 64
      pages/orderFood/index.vue

+ 4 - 0
api/dining.js

@@ -62,6 +62,10 @@ export const GetStoreCategories = (params) => api.get({ url: '/store/info/catego
 // 根据菜品种类获取菜品(入参 categoryId,GET /store/info/cuisines?categoryId=)
 export const GetStoreCuisines = (params) => api.get({ url: '/store/info/cuisines', params });
 
+// 左侧分类+右侧菜品一体(GET /dining/store/info/categories-with-cuisines,入参 storeId,keyword 选填)
+export const GetCategoriesWithCuisines = (params) =>
+  api.get({ url: '/store/info/categories-with-cuisines', params });
+
 // 菜品详情(GET /store/dining/cuisine/{cuisineId},入参 cuisineId 菜品ID、tableId 桌号ID 传 query)
 export const GetCuisineDetail = (cuisineId, tableId) => {
   const id = cuisineId != null ? encodeURIComponent(cuisineId) : '';

+ 12 - 3
pages/orderFood/components/FoodCard.vue

@@ -102,7 +102,7 @@ const normalizedTags = computed(() => {
   let raw = food?.tags ?? food?.tagList ?? food?.tagNames ?? food?.labels ?? food?.tag;
   if (raw == null) return [];
 
-  // 第一步:先把各种类型统一转为数组
+  // 第一步:先把各种类型统一转为数组(保证最后每个 tag 一个 view)
   let arr = [];
   if (Array.isArray(raw)) {
     arr = raw;
@@ -119,11 +119,14 @@ const normalizedTags = computed(() => {
       arr = trimmed ? trimmed.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean) : [];
     }
   } else if (raw && typeof raw === 'object') {
-    arr = Array.isArray(raw.list) ? raw.list : Array.isArray(raw.items) ? raw.items : [];
+    if (Array.isArray(raw.list)) arr = raw.list;
+    else if (Array.isArray(raw.items)) arr = raw.items;
+    else if (raw.text != null || raw.tagName != null || raw.name != null || raw.label != null || raw.title != null) arr = [raw];
+    else arr = [];
   }
 
   // 第二步:将数组每一项转为 { text, type }
-  return arr.map(item => {
+  const withText = arr.map(item => {
     if (item == null) return { text: '', type: '' };
     if (typeof item === 'string') return { text: item, type: '' };
     if (typeof item === 'number') return { text: String(item), type: '' };
@@ -132,6 +135,12 @@ const normalizedTags = computed(() => {
       type: item.type ?? item.tagType ?? ''
     };
   }).filter(t => t.text !== '' && t.text != null);
+
+  // 第三步:text 内若含逗号/顿号/空格则拆成多条,几个 tag 就几个 view
+  return withText.flatMap((t) => {
+    const parts = (t.text || '').split(/[,,、\s]+/).map((s) => s.trim()).filter(Boolean);
+    return parts.map((p) => ({ text: p, type: t.type }));
+  });
 });
 
 const handleFoodClick = () => {

+ 137 - 64
pages/orderFood/index.vue

@@ -7,7 +7,8 @@
       <view class="search-box">
         <view class="search-box-inner">
           <image :src="getFileUrl('img/personal/search.png')" mode="widthFix" class="search-icon"></image>
-          <input type="text" placeholder="搜索菜品名称" class="search-input" />
+          <input type="text" placeholder="搜索菜品名称" class="search-input" v-model="searchKeyword"
+            @confirm="doSearch" />
         </view>
       </view>
     </view>
@@ -24,11 +25,16 @@
         </scroll-view>
       </view>
 
-      <!-- 右侧菜品列表 -->
+      <!-- 右侧菜品列表:按分类分段展示,每段标题 + 该分类下菜品 -->
       <view class="food-list-wrap">
-        <scroll-view class="food-list" scroll-y>
-          <FoodCard v-for="(food, index) in currentFoodList" :key="food.id || food.cuisineId || index" :food="food"
-            :table-id="tableId" @increase="handleIncrease" @decrease="handleDecrease"/>
+        <scroll-view class="food-list" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation>
+          <block v-for="(section, sectionIndex) in sectionList" :key="section.category.id ?? sectionIndex">
+            <view :id="'section-' + sectionIndex" class="food-section">
+              <view class="food-section__title">{{ section.category.categoryName }}</view>
+              <FoodCard v-for="(food, index) in section.cuisines" :key="food.id || food.cuisineId || sectionIndex + '-' + index"
+                :food="food" :table-id="tableId" @increase="handleIncrease" @decrease="handleDecrease"/>
+            </view>
+          </block>
         </scroll-view>
       </view>
     </view>
@@ -64,7 +70,7 @@ import CartModal from "./components/CartModal.vue";
 import SelectCouponModal from "./components/SelectCouponModal.vue";
 import { go } from "@/utils/utils.js";
 import { getFileUrl } from "@/utils/file.js";
-import { DiningOrderFood, GetStoreCategories, GetStoreCuisines, GetStoreDetail, getOrderSseConfig, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate, PostOrderCartClear, PutOrderCartUpdateTableware, GetUserOwnedCouponList } from "@/api/dining.js";
+import { DiningOrderFood, GetCategoriesWithCuisines, GetStoreDetail, getOrderSseConfig, GetOrderCart, PostOrderCartAdd, PostOrderCartUpdate, PostOrderCartClear, PutOrderCartUpdateTableware, GetUserOwnedCouponList } from "@/api/dining.js";
 import { createSSEConnection } from "@/utils/sse.js";
 
 // 商品图片:取第一张,若为逗号分隔字符串则截取逗号前的第一张
@@ -92,6 +98,7 @@ const displayTableNumber = computed(() => {
 let sseRequestTask = null; // 订单 SSE 连接(封装后兼容小程序),页面卸载时需 abort()
 const currentCategoryIndex = ref(0);
 const foodListScrollTop = ref(0);
+const searchKeyword = ref('');
 const couponModalOpen = ref(false);
 const cartModalOpen = ref(false);
 const selectCouponModalOpen = ref(false);
@@ -110,14 +117,24 @@ let pendingCartData = null;
 // 购物车接口返回的完整数据:{ items, totalAmount, totalQuantity, tablewareFee },用于按接口格式展示
 const cartData = ref({ items: [], totalAmount: 0, totalQuantity: 0, tablewareFee: 0 });
 
-// 当前分类的菜品列表(按当前选中的分类 id 过滤)
-const currentFoodList = computed(() => {
-  const cat = categories.value[currentCategoryIndex.value];
-  if (!cat) return [];
-  const categoryId = cat.id ?? cat.categoryId;
-  return foodList.value.filter((food) => String(food?.categoryId ?? '') === String(categoryId ?? ''));
+// 右侧长列表:按分类分段,每段为 { category, cuisines },支持一菜多分类(categoryIds)
+const sectionList = computed(() => {
+  const cats = categories.value;
+  const list = foodList.value;
+  if (!cats.length) return [];
+  return cats.map((cat) => {
+    const cid = String(cat.id ?? cat.categoryId ?? '');
+    const cuisines = list.filter((f) => {
+      const ids = f.categoryIds ?? (f.categoryId != null ? [f.categoryId] : []);
+      return (Array.isArray(ids) ? ids : [ids]).map(String).includes(cid);
+    });
+    return { category: cat, cuisines };
+  });
 });
 
+// 点击左侧分类时滚动到右侧对应段(scroll-into-view 用)
+const scrollIntoViewId = ref('');
+
 // 购物车列表:只包含数量>0 的菜品,并按菜品 id 去重(同一菜品只算一条,避免金额叠加)
 const cartList = computed(() => {
   const withQty = foodList.value.filter((food) => (food.quantity || 0) > 0);
@@ -147,7 +164,7 @@ const displayCartList = computed(() => {
   return base;
 });
 
-// 将 tags 统一为 [{ text, type }] 格式(与 FoodCard 一致)
+// 将 tags 统一为 [{ text, type }] 格式,且 text 含逗号/顿号时拆成多条(与 FoodCard 一致,分开显示
 function normalizeTags(raw) {
   if (raw == null) return [];
   let arr = [];
@@ -156,13 +173,22 @@ function normalizeTags(raw) {
     const t = raw.trim();
     if (t.startsWith('[')) { try { arr = JSON.parse(t); if (!Array.isArray(arr)) arr = []; } catch { arr = t ? [t] : []; } }
     else arr = t ? t.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean) : [];
-  } else if (raw && typeof raw === 'object') arr = Array.isArray(raw.list) ? raw.list : Array.isArray(raw.items) ? raw.items : [];
-  return arr.map((item) => {
+  } else if (raw && typeof raw === 'object') {
+    if (Array.isArray(raw.list)) arr = raw.list;
+    else if (Array.isArray(raw.items)) arr = raw.items;
+    else if (raw.text != null || raw.tagName != null || raw.name != null || raw.label != null || raw.title != null) arr = [raw];
+    else arr = [];
+  }
+  const withText = arr.map((item) => {
     if (item == null) return { text: '', type: '' };
     if (typeof item === 'string') return { text: item, type: '' };
     if (typeof item === 'number') return { text: String(item), type: '' };
     return { text: item.text ?? item.tagName ?? item.name ?? item.label ?? item.title ?? '', type: item.type ?? item.tagType ?? '' };
   }).filter((t) => t.text !== '' && t.text != null);
+  return withText.flatMap((t) => {
+    const parts = (t.text || '').split(/[,,、\s]+/).map((s) => s.trim()).filter(Boolean);
+    return parts.map((p) => ({ text: p, type: t.type }));
+  });
 }
 
 // 购物车展示列表(含标签):从 foodList 补全 cart 项缺失的 tags
@@ -314,42 +340,99 @@ const fetchAndMergeCart = async (tableid) => {
   }
 };
 
-// 根据分类 id 拉取菜品并合并到 foodList,切换分类时保留其他分类已选菜品及本分类已选数量
-const fetchCuisinesByCategoryId = async (categoryId) => {
-  if (!categoryId) return;
+// 拉取「分类+菜品」一体接口,填充左侧分类与右侧菜品列表(入参 storeId,keyword 选填)
+const fetchCategoriesWithCuisines = async (storeId, keyword = '') => {
+  if (!storeId) return;
   try {
-    const res = await GetStoreCuisines({ categoryId });
-    const list = res?.list ?? res?.data ?? (Array.isArray(res) ? res : []);
-    const normalized = (Array.isArray(list) ? list : []).map(item => {
+    const params = { storeId };
+    if (keyword != null && String(keyword).trim() !== '') params.keyword = String(keyword).trim();
+    const res = await GetCategoriesWithCuisines(params);
+    const raw = res?.data ?? res ?? {};
+    const list = raw?.list ?? raw?.data ?? (Array.isArray(raw) ? raw : []);
+    const listArr = Array.isArray(list) ? list : [];
+    // cuisines 为空的项不加入列表,左侧分类与右侧分段都不展示
+    const dataList = listArr.filter((item) => {
+      const arr = item.cuisines ?? item.cuisineList ?? [];
+      return Array.isArray(arr) && arr.length > 0;
+    });
+    let cats = [];
+    let cuisines = [];
+    // 接口返回 data 数组,每项为 { category: { id, categoryName, ... }, cuisines: [...] }
+    if (dataList.length > 0 && dataList[0].category != null) {
+      cats = dataList.map((item) => {
+        const c = item.category || {};
+        return {
+          id: c.id ?? c.categoryId,
+          categoryId: c.id ?? c.categoryId,
+          categoryName: c.categoryName ?? c.name ?? ''
+        };
+      });
+      cuisines = dataList.flatMap((item) => {
+        const c = item.category || {};
+        const cid = c.id ?? c.categoryId;
+        const arr = item.cuisines ?? item.cuisineList ?? [];
+        return (Array.isArray(arr) ? arr : []).map((dish) => {
+          const ids = Array.isArray(dish.categoryIds) ? dish.categoryIds : (dish.categoryId != null ? [dish.categoryId] : [cid]);
+          return { ...dish, categoryId: dish.categoryId ?? dish.categoryIds?.[0] ?? cid, categoryIds: ids };
+        });
+      });
+    } else if (dataList.length > 0 && (dataList[0].cuisines != null || dataList[0].cuisineList != null)) {
+      const cuisinesKey = dataList[0].cuisines != null ? 'cuisines' : 'cuisineList';
+      cats = dataList.map((c) => ({
+        id: c.id ?? c.categoryId,
+        categoryId: c.id ?? c.categoryId,
+        categoryName: c.categoryName ?? c.name ?? ''
+      }));
+      cuisines = dataList.flatMap((c) => {
+        const arr = c[cuisinesKey] ?? [];
+        const cid = c.id ?? c.categoryId;
+        return (Array.isArray(arr) ? arr : []).map((item) => {
+          const ids = Array.isArray(item.categoryIds) ? item.categoryIds : (item.categoryId != null ? [item.categoryId] : [cid]);
+          return { ...item, categoryId: item.categoryId ?? item.categoryIds?.[0] ?? cid, categoryIds: ids };
+        });
+      });
+    } else {
+      cats = raw?.categories ?? raw?.categoryList ?? [];
+      const cuisinesRaw = raw?.cuisines ?? raw?.cuisineList ?? [];
+      cuisines = Array.isArray(cuisinesRaw) ? cuisinesRaw : [];
+    }
+    categories.value = Array.isArray(cats) ? cats : [];
+    const normalized = cuisines.map((item) => {
+      const categoryId = item.categoryId ?? (categories.value[0]?.id ?? categories.value[0]?.categoryId);
       const rawImg = item.images ?? item.cuisineImage ?? item.image ?? item.imageUrl ?? item.pic ?? item.cover ?? '';
       const img = firstImage(rawImg) || (typeof rawImg === 'string' ? rawImg : (rawImg && (rawImg.url ?? rawImg.path ?? rawImg.src) ? (rawImg.url ?? rawImg.path ?? rawImg.src) : ''));
-      return { ...item, images: img, image: img, cuisineImage: img, quantity: item.quantity ?? 0, categoryId: item.categoryId ?? categoryId };
+      return { ...item, images: img, image: img, cuisineImage: img, quantity: item.quantity ?? 0, categoryId, categoryIds: item.categoryIds ?? [categoryId] };
     });
-    // 其他分类的菜品原样保留(含已选数量)
-    const rest = foodList.value.filter(f => !sameCategory(f.categoryId, categoryId));
-    // 本分类:按菜品 id 从整份列表里取已选数量,id 一致则自动带上数量
-    const merged = normalized.map((newItem) => {
-      const existing = foodList.value.find(
-        (e) => String(e?.id ?? e?.cuisineId ?? '') === String(newItem?.id ?? newItem?.cuisineId ?? '')
-      );
-      const quantity = existing ? existing.quantity : (newItem.quantity ?? 0);
-      return { ...newItem, quantity };
+    // 同一菜品可能出现在多个分类下,按 id 去重并合并 categoryIds
+    const byId = new Map();
+    normalized.forEach((item) => {
+      const id = String(item.id ?? item.cuisineId ?? '');
+      if (byId.has(id)) {
+        const existing = byId.get(id);
+        const merged = [...new Set([...(existing.categoryIds || []), ...(item.categoryIds || [])].map(String))];
+        byId.set(id, { ...existing, categoryIds: merged });
+      } else {
+        byId.set(id, item);
+      }
     });
-    foodList.value = [...rest, ...merged];
-    // 切换分类后,把购物车数量再同步到当前分类的菜品列表
+    foodList.value = Array.from(byId.values());
     mergeCartIntoFoodList();
   } catch (err) {
-    console.error('获取菜品失败:', err);
+    console.error('获取分类与菜品失败:', err);
   }
 };
 
-// 选择分类:切换高亮并拉取该分类菜品
-const selectCategory = async (index) => {
+// 选择分类:切换高亮并滚动右侧到对应分段
+const selectCategory = (index) => {
   currentCategoryIndex.value = index;
-  const cat = categories.value[index];
-  if (!cat) return;
-  const categoryId = cat.id ?? cat.categoryId;
-  await fetchCuisinesByCategoryId(categoryId);
+  scrollIntoViewId.value = 'section-' + index;
+  setTimeout(() => { scrollIntoViewId.value = ''; }, 400);
+};
+
+// 搜索:带 keyword 重新请求分类+菜品
+const doSearch = () => {
+  const storeId = uni.getStorageSync('currentStoreId') || '';
+  if (storeId) fetchCategoriesWithCuisines(storeId, searchKeyword.value?.trim() ?? '');
 };
 
 // 根据展示项(可能来自接口 cuisineId 或 foodList 的 id)找到 foodList 中的菜品
@@ -754,30 +837,7 @@ onLoad(async (options) => {
         } catch (_) {}
       }
       if (storeId) {
-        try {
-          const categoriesRes = await GetStoreCategories({ storeId });
-          const list = categoriesRes?.list ?? categoriesRes?.data ?? categoriesRes;
-          if (Array.isArray(list) && list.length) {
-            categories.value = list;
-            // 默认用第一项的分类 id 拉取菜品
-            const firstCat = list[0];
-            const firstCategoryId = firstCat.id ?? firstCat.categoryId;
-            if (firstCategoryId) {
-              const cuisinesRes = await GetStoreCuisines({ categoryId: firstCategoryId });
-              const cuisinesList = cuisinesRes?.list ?? cuisinesRes?.data ?? (Array.isArray(cuisinesRes) ? cuisinesRes : []);
-              foodList.value = (Array.isArray(cuisinesList) ? cuisinesList : []).map(item => {
-                const rawImg = item.images ?? item.cuisineImage ?? item.image ?? item.imageUrl ?? item.pic ?? item.cover ?? '';
-                const img = firstImage(rawImg) || (typeof rawImg === 'string' ? rawImg : (rawImg && (rawImg.url ?? rawImg.path ?? rawImg.src) ? (rawImg.url ?? rawImg.path ?? rawImg.src) : ''));
-                return { ...item, images: img, image: img, cuisineImage: img, quantity: item.quantity ?? 0, categoryId: item.categoryId ?? firstCategoryId };
-              });
-              console.log('默认分类菜品:', cuisinesRes);
-              mergeCartIntoFoodList();
-            }
-          }
-          console.log('菜品种类:', categoriesRes);
-        } catch (err) {
-          console.error('获取菜品种类失败:', err);
-        }
+        await fetchCategoriesWithCuisines(storeId, searchKeyword.value?.trim() ?? '');
       }
     } catch (e) {
       console.error('点餐页接口失败:', e);
@@ -944,4 +1004,17 @@ onUnload(() => {
   min-height: 0;
   height: 100%;
 }
+
+.food-section {
+  margin-bottom: 24rpx;
+
+  &__title {
+    font-size: 28rpx;
+    font-weight: 600;
+    color: #333;
+    padding: 16rpx 0 12rpx;
+    background-color: #f7f9fa;
+    padding-left: 20rpx;
+  }
+}
 </style>