|
|
@@ -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>
|