Просмотр исходного кода

feat(mallApp): 新增商品列表筛选入口

- 金刚区导航按分类或场景跳转商品列表,并携带初始筛选参数

- 新增商品列表页,支持搜索、分页加载、价格/分类/场景筛选和商品详情跳转
shizhongqi 3 недель назад
Родитель
Сommit
ebc6311d76

+ 8 - 0
mallApp/src/api/category/index.js

@@ -9,11 +9,19 @@ export const getClass = data => {
 
 /**
  * 获取分类的商品 m
+ * 可选筛选:categoryIds、useCaseIds、minPrice、maxPrice、searchText、page、pageSize、requestType
  */
 export const getList = data => {
 	return https.get('/category/goods-list', data)
 }
 
+/**
+ * 获取场景列表(筛选弹窗用)m
+ */
+export const getUseCaseList = data => {
+	return https.get('/category/use-case-list', data)
+}
+
 /** *
  * 商品分类-列表(全部分类无分页) b
  */

+ 16 - 3
mallApp/src/components/home/navGrid.vue

@@ -1,7 +1,7 @@
 <!--
   店铺首页-分类金刚区导航
   按 cols(4/5) 渲染图标网格;预设图标本地映射为 emoji,上传图标使用后端返回完整URL;
-  点击导航项先跳店铺分类浏览页(暂未打通按分类/场景精确筛选跳转)
+  点击导航项跳转商品列表页,并按关联类型(分类/场景)带上初始筛选条件
 -->
 <template>
   <view v-if="data && data.enabled == 1 && data.list && data.list.length" class="home-nav-grid" :class="'cols-' + (data.cols || 5)">
@@ -55,8 +55,21 @@ export default {
     getPresetEmoji(icon) {
       return PRESET_EMOJI_MAP[icon] || '📦'
     },
-    onTap() {
-      this.pageTo({ url: `/pages/home/category?account=${this.account}&hdId=${this.hdId}` })
+    /**
+     * 点击金刚区项:跳转商品列表,并按关联类型带上初始筛选
+     * type=3 分类 → filterType=3&filterValue=分类ID串
+     * type=4 场景 → filterType=4&filterValue=场景ID串
+     */
+    onTap(item) {
+      const account = this.account || ''
+      const hdId = this.hdId || ''
+      let url = `/pages/goods/list?account=${account}&hdId=${hdId}`
+      const type = parseInt(item && item.type, 10)
+      const value = item && item.value ? String(item.value) : ''
+      if ((type === 3 || type === 4) && value) {
+        url += `&filterType=${type}&filterValue=${encodeURIComponent(value)}`
+      }
+      this.pageTo({ url })
     }
   }
 }

+ 2 - 1
mallApp/src/pages.json

@@ -74,7 +74,8 @@
         {
             "root": "pages/goods",
             "pages": [
-                { "path": "detail", "style": { "navigationBarTitleText": "详情" } }
+                { "path": "detail", "style": { "navigationBarTitleText": "详情" } },
+                { "path": "list", "style": { "navigationBarTitleText": "商品列表", "enablePullDownRefresh": true } }
             ]
         },
         {

+ 705 - 0
mallApp/src/pages/goods/list.vue

@@ -0,0 +1,705 @@
+<!--
+  商品列表页
+  从店铺首页金刚区进入;支持搜索、分页触底加载、筛选弹窗(配送占位/价格/分类/场景)
+  按钮配色与店铺首页统一:#FF4D6D / linear-gradient(135deg, #FF8FA3, #FF4D6D)
+-->
+<template>
+  <view class="goods-list-page">
+    <!-- 搜索栏 -->
+    <view class="search-bar">
+      <view class="search-input-wrap">
+        <app-search-module
+          v-model="searchText"
+          placeholder="请输入搜索商品名称"
+          :height="66"
+          :fontSize="28"
+          backColor="#fff"
+          @input="onSearchInput"
+          @search="doSearch"
+        />
+      </view>
+      <view class="search-btn" @click="doSearch">搜索</view>
+      <view class="filter-btn" @click="openFilter">筛选</view>
+    </view>
+
+    <!-- 商品列表 -->
+    <view v-if="!$util.isEmpty(list.data)" class="card-list">
+      <view
+        v-for="item in list.data"
+        :key="item.id"
+        class="goods-card"
+        @click="goDetail(item)"
+      >
+        <view class="cover-wrap">
+          <image class="cover" :src="item.smallCover || item.cover" mode="aspectFill" />
+          <view v-if="item.badgeText" class="badge" :class="item.badgeClass">
+            {{ item.badgeText }}
+          </view>
+        </view>
+        <view class="card-body">
+          <text class="goods-name">{{ item.name }}</text>
+          <view v-if="item.flowerNum > 0" class="spec-text">{{ item.flowerNum }}支/扎</view>
+          <view v-if="item.useCaseNames && item.useCaseNames.length" class="tag-row">
+            <text
+              v-for="(tag, idx) in item.useCaseNames"
+              :key="idx"
+              class="tag-pill"
+            >{{ tag }}</text>
+          </view>
+          <view class="bottom-row">
+            <text class="price">
+              <text class="price-symbol">¥</text>
+              {{ formatPrice(item.price) }}
+              <text class="price-suffix">起</text>
+            </text>
+            <text class="sold">已售{{ formatSold(item.totalSold) }}件</text>
+          </view>
+        </view>
+      </view>
+      <view v-if="list.finished" class="list-end">已经到底了</view>
+    </view>
+    <block v-else-if="!list.loading">
+      <app-wrapper-empty title="暂无商品" :is-empty="true" />
+    </block>
+
+    <!-- 筛选弹窗 -->
+    <bottom-popup :show="filterShow" bgcolor="#f5f5f5" :height="0" @close="closeFilter">
+      <view class="filter-panel">
+        <view class="filter-title">全部筛选</view>
+        <scroll-view scroll-y class="filter-scroll">
+          <!-- 配送方式(占位,不参与请求) -->
+          <view class="filter-section">
+            <text class="section-label">配送方式</text>
+            <view class="option-grid">
+              <view
+                v-for="opt in deliveryOptions"
+                :key="opt.value"
+                class="option-item"
+                :class="{ active: draft.delivery === opt.value }"
+                @click="toggleDelivery(opt.value)"
+              >{{ opt.label }}</view>
+            </view>
+          </view>
+
+          <!-- 价格区间 -->
+          <view class="filter-section">
+            <text class="section-label">价格区间</text>
+            <view class="option-grid cols-4">
+              <view
+                v-for="opt in pricePresets"
+                :key="opt.key"
+                class="option-item"
+                :class="{ active: draft.pricePreset === opt.key }"
+                @click="selectPricePreset(opt)"
+              >{{ opt.label }}</view>
+            </view>
+            <view class="custom-price-row">
+              <input
+                class="price-input"
+                type="digit"
+                v-model="draft.customMin"
+                placeholder="自定义最低价"
+                @input="onCustomPriceInput"
+              />
+              <text class="price-dash">—</text>
+              <input
+                class="price-input"
+                type="digit"
+                v-model="draft.customMax"
+                placeholder="自定义最高价"
+                @input="onCustomPriceInput"
+              />
+            </view>
+          </view>
+
+          <!-- 分类 -->
+          <view class="filter-section">
+            <text class="section-label">分类</text>
+            <view class="option-grid">
+              <view
+                v-for="cat in categoryOptions"
+                :key="cat.id"
+                class="option-item"
+                :class="{ active: isCategoryChecked(cat.id) }"
+                @click="toggleCategory(cat.id)"
+              >{{ cat.categoryName }}</view>
+              <view v-if="!categoryOptions.length" class="option-empty">暂无分类</view>
+            </view>
+          </view>
+
+          <!-- 场景 -->
+          <view class="filter-section">
+            <text class="section-label">场景</text>
+            <view class="option-grid">
+              <view
+                v-for="uc in useCaseOptions"
+                :key="uc.id"
+                class="option-item"
+                :class="{ active: isUseCaseChecked(uc.id) }"
+                @click="toggleUseCase(uc.id)"
+              >{{ uc.useCaseName }}</view>
+              <view v-if="!useCaseOptions.length" class="option-empty">暂无场景</view>
+            </view>
+          </view>
+          <view class="filter-scroll-space"></view>
+        </scroll-view>
+        <view class="filter-footer">
+          <view class="confirm-btn" @click="confirmFilter">确认</view>
+        </view>
+      </view>
+    </bottom-popup>
+  </view>
+</template>
+
+<script>
+import AppSearchModule from '@/components/item/module/app-search'
+import AppWrapperEmpty from '@/components/app-wrapper-empty'
+import BottomPopup from '@/components/plugin/bottom-popup'
+import { getClass, getList, getUseCaseList } from '@/api/category'
+import { list } from '@/mixins'
+
+const PRICE_PRESETS = [
+  { key: 'lt50', label: '50元以下', min: '', max: '50' },
+  { key: '50_100', label: '50~100元', min: '50', max: '100' },
+  { key: '100_200', label: '100~200元', min: '100', max: '200' },
+  { key: 'gt200', label: '200元以上', min: '200', max: '' }
+]
+
+const DELIVERY_OPTIONS = [
+  { label: '跑腿配送', value: 'errand' },
+  { label: '到店自取', value: 'pickup' },
+  { label: '商家配送', value: 'merchant' },
+  { label: '快递', value: 'express' },
+  { label: '物流', value: 'logistics' }
+]
+
+const emptyDraft = () => ({
+  delivery: '',
+  pricePreset: '',
+  customMin: '',
+  customMax: '',
+  categoryIds: [],
+  useCaseIds: []
+})
+
+export default {
+  name: 'goodsList',
+  components: {
+    AppSearchModule,
+    AppWrapperEmpty,
+    BottomPopup
+  },
+  mixins: [list],
+  data() {
+    return {
+      searchText: '',
+      searchTimer: null,
+      filterShow: false,
+      categoryOptions: [],
+      useCaseOptions: [],
+      deliveryOptions: DELIVERY_OPTIONS,
+      pricePresets: PRICE_PRESETS,
+      // 已生效的筛选条件
+      filters: {
+        categoryIds: [],
+        useCaseIds: [],
+        minPrice: '',
+        maxPrice: '',
+        delivery: ''
+      },
+      // 弹窗内草稿
+      draft: emptyDraft()
+    }
+  },
+  methods: {
+    init() {
+      this.applyRouteFilter()
+      this.loadFilterOptions()
+      this.loadGoods()
+    },
+    /** 解析金刚区带入的 filterType / filterValue */
+    applyRouteFilter() {
+      const type = parseInt((this.option && this.option.filterType) || 0, 10)
+      const value = (this.option && this.option.filterValue) ? String(this.option.filterValue) : ''
+      const ids = value.split(',').map((s) => s.trim()).filter(Boolean)
+      if (type === 3 && ids.length) {
+        this.filters.categoryIds = ids
+      } else if (type === 4 && ids.length) {
+        this.filters.useCaseIds = ids
+      }
+    },
+    loadFilterOptions() {
+      getClass().then((res) => {
+        if (res.code == 1) {
+          this.categoryOptions = res.data || []
+        }
+      })
+      getUseCaseList().then((res) => {
+        if (res.code == 1) {
+          this.useCaseOptions = res.data || []
+        }
+      })
+    },
+    buildQuery() {
+      const params = {
+        page: this.list.page,
+        pageSize: 10,
+        requestType: 'mall'
+      }
+      if (this.searchText) {
+        params.searchText = this.searchText
+      }
+      if (this.filters.categoryIds.length) {
+        params.categoryIds = this.filters.categoryIds.join(',')
+      }
+      if (this.filters.useCaseIds.length) {
+        params.useCaseIds = this.filters.useCaseIds.join(',')
+      }
+      if (this.filters.minPrice !== '' && this.filters.minPrice != null) {
+        params.minPrice = this.filters.minPrice
+      }
+      if (this.filters.maxPrice !== '' && this.filters.maxPrice != null) {
+        params.maxPrice = this.filters.maxPrice
+      }
+      return params
+    },
+    loadGoods() {
+      this.list.loading = true
+      return getList(this.buildQuery())
+        .then((res) => {
+          this.completes(res)
+        })
+        .catch(() => {
+          this.list.loading = false
+          this.list.finished = true
+        })
+    },
+    onSearchInput() {
+      if (this.searchTimer) {
+        clearTimeout(this.searchTimer)
+        this.searchTimer = null
+      }
+      this.searchTimer = setTimeout(() => {
+        this.doSearch()
+      }, 600)
+    },
+    doSearch() {
+      if (this.searchTimer) {
+        clearTimeout(this.searchTimer)
+        this.searchTimer = null
+      }
+      this.resetList()
+      this.loadGoods()
+    },
+    openFilter() {
+      const presetKey = this.resolvePresetKey(this.filters.minPrice, this.filters.maxPrice)
+      this.draft = {
+        delivery: this.filters.delivery || '',
+        pricePreset: presetKey,
+        customMin: presetKey ? '' : (this.filters.minPrice === '' || this.filters.minPrice == null ? '' : String(this.filters.minPrice)),
+        customMax: presetKey ? '' : (this.filters.maxPrice === '' || this.filters.maxPrice == null ? '' : String(this.filters.maxPrice)),
+        categoryIds: [...this.filters.categoryIds],
+        useCaseIds: [...this.filters.useCaseIds]
+      }
+      this.filterShow = true
+    },
+    closeFilter() {
+      this.filterShow = false
+    },
+    resolvePresetKey(min, max) {
+      const minStr = min === '' || min == null ? '' : String(min)
+      const maxStr = max === '' || max == null ? '' : String(max)
+      const found = PRICE_PRESETS.find((p) => p.min === minStr && p.max === maxStr)
+      return found ? found.key : ''
+    },
+    toggleDelivery(value) {
+      this.draft.delivery = this.draft.delivery === value ? '' : value
+    },
+    selectPricePreset(opt) {
+      if (this.draft.pricePreset === opt.key) {
+        this.draft.pricePreset = ''
+      } else {
+        this.draft.pricePreset = opt.key
+        this.draft.customMin = ''
+        this.draft.customMax = ''
+      }
+    },
+    onCustomPriceInput() {
+      if (this.draft.customMin || this.draft.customMax) {
+        this.draft.pricePreset = ''
+      }
+    },
+    isCategoryChecked(id) {
+      return this.draft.categoryIds.indexOf(String(id)) > -1
+    },
+    toggleCategory(id) {
+      const key = String(id)
+      const idx = this.draft.categoryIds.indexOf(key)
+      if (idx > -1) {
+        this.draft.categoryIds.splice(idx, 1)
+      } else {
+        this.draft.categoryIds.push(key)
+      }
+    },
+    isUseCaseChecked(id) {
+      return this.draft.useCaseIds.indexOf(String(id)) > -1
+    },
+    toggleUseCase(id) {
+      const key = String(id)
+      const idx = this.draft.useCaseIds.indexOf(key)
+      if (idx > -1) {
+        this.draft.useCaseIds.splice(idx, 1)
+      } else {
+        this.draft.useCaseIds.push(key)
+      }
+    },
+    confirmFilter() {
+      let minPrice = ''
+      let maxPrice = ''
+      if (this.draft.customMin || this.draft.customMax) {
+        minPrice = this.draft.customMin
+        maxPrice = this.draft.customMax
+      } else if (this.draft.pricePreset) {
+        const preset = PRICE_PRESETS.find((p) => p.key === this.draft.pricePreset)
+        if (preset) {
+          minPrice = preset.min
+          maxPrice = preset.max
+        }
+      }
+      this.filters = {
+        categoryIds: [...this.draft.categoryIds],
+        useCaseIds: [...this.draft.useCaseIds],
+        minPrice,
+        maxPrice,
+        delivery: this.draft.delivery
+      }
+      this.filterShow = false
+      this.resetList()
+      this.loadGoods()
+    },
+    formatPrice(val) {
+      const n = parseFloat(val)
+      if (isNaN(n)) return '0'
+      return n % 1 === 0 ? String(n) : n.toFixed(2)
+    },
+    formatSold(val) {
+      const n = parseInt(val, 10) || 0
+      if (n >= 10000) return (n / 10000).toFixed(1) + '万+'
+      return String(n)
+    },
+    goDetail(item) {
+      const account = (this.option && this.option.account) || uni.getStorageSync('account') || ''
+      const hdId = (this.option && this.option.hdId) || uni.getStorageSync('hdId') || ''
+      this.pageTo({
+        url: `/pages/goods/detail?id=${item.id}&account=${account}&hdId=${hdId}`
+      })
+    }
+  },
+  async onPullDownRefresh() {
+    this.resetList()
+    await this.loadGoods()
+    uni.stopPullDownRefresh()
+  },
+  async onReachBottom() {
+    if (!this.list.finished) {
+      await this.loadGoods()
+    }
+  },
+  beforeDestroy() {
+    if (this.searchTimer) {
+      clearTimeout(this.searchTimer)
+      this.searchTimer = null
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.goods-list-page {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding-bottom: 40upx;
+}
+
+.search-bar {
+  display: flex;
+  align-items: center;
+  padding: 16upx 20upx;
+  background: #fff;
+  position: sticky;
+  top: 0;
+  z-index: 20;
+}
+
+.search-input-wrap {
+  flex: 1;
+  min-width: 0;
+  border: 1upx solid #e5e5e5;
+  border-radius: 32upx;
+  overflow: hidden;
+}
+
+.search-btn,
+.filter-btn {
+  flex-shrink: 0;
+  margin-left: 12upx;
+  padding: 0 28upx;
+  height: 64upx;
+  line-height: 64upx;
+  font-size: 26upx;
+  color: #fff;
+  background: linear-gradient(135deg, #FF8FA3, #FF4D6D);
+  border-radius: 32upx;
+  text-align: center;
+}
+
+.card-list {
+  padding: 20upx;
+}
+
+.goods-card {
+  display: flex;
+  background: #fff;
+  border-radius: 16upx;
+  padding: 20upx;
+  margin-bottom: 20upx;
+  box-shadow: 0 4upx 16upx rgba(0, 0, 0, 0.04);
+}
+
+.cover-wrap {
+  position: relative;
+  width: 200upx;
+  height: 200upx;
+  flex-shrink: 0;
+  border-radius: 12upx;
+  overflow: hidden;
+  background: #f5f5f5;
+}
+
+.cover {
+  width: 100%;
+  height: 100%;
+}
+
+.badge {
+  position: absolute;
+  left: 0;
+  top: 0;
+  padding: 4upx 12upx;
+  font-size: 20upx;
+  color: #fff;
+  border-radius: 0 0 12upx 0;
+  background: #FF4D6D;
+
+  &.new {
+    background: #09C567;
+  }
+}
+
+.card-body {
+  flex: 1;
+  min-width: 0;
+  margin-left: 20upx;
+  display: flex;
+  flex-direction: column;
+}
+
+.goods-name {
+  font-size: 30upx;
+  font-weight: 700;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  line-height: 1.4;
+}
+
+.spec-text {
+  margin-top: 8upx;
+  font-size: 22upx;
+  color: #999;
+}
+
+.tag-row {
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 12upx;
+}
+
+.tag-pill {
+  margin-right: 10upx;
+  margin-bottom: 8upx;
+  padding: 4upx 14upx;
+  font-size: 20upx;
+  color: #666;
+  background: #f5f5f5;
+  border-radius: 20upx;
+}
+
+.bottom-row {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  margin-top: auto;
+  padding-top: 12upx;
+}
+
+.price {
+  font-size: 34upx;
+  font-weight: 700;
+  color: #FF4D6D;
+}
+
+.price-symbol {
+  font-size: 24upx;
+  margin-right: 2upx;
+}
+
+.price-suffix {
+  font-size: 22upx;
+  font-weight: 400;
+  margin-left: 4upx;
+}
+
+.sold {
+  font-size: 22upx;
+  color: #999;
+}
+
+.list-end {
+  padding: 30upx 0 20upx;
+  text-align: center;
+  font-size: 24upx;
+  color: #bbb;
+}
+
+/* 筛选弹窗 */
+.filter-panel {
+  display: flex;
+  flex-direction: column;
+  height: 80vh;
+  max-height: 1100upx;
+  background: #f5f5f5;
+  border-radius: 24upx 24upx 0 0;
+  overflow: hidden;
+}
+
+.filter-title {
+  flex-shrink: 0;
+  padding: 28upx 0 16upx;
+  text-align: center;
+  font-size: 32upx;
+  font-weight: 700;
+  color: #333;
+  background: #f5f5f5;
+}
+
+.filter-scroll {
+  flex: 1;
+  height: 0;
+  padding: 0 24upx;
+  box-sizing: border-box;
+}
+
+.filter-section {
+  margin-bottom: 20upx;
+  padding: 24upx;
+  background: #fff;
+  border-radius: 16upx;
+}
+
+.section-label {
+  display: block;
+  margin-bottom: 20upx;
+  font-size: 28upx;
+  font-weight: 600;
+  color: #333;
+}
+
+.option-grid {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -8upx;
+}
+
+.option-item {
+  box-sizing: border-box;
+  width: calc(33.33% - 16upx);
+  margin: 0 8upx 16upx;
+  padding: 16upx 8upx;
+  text-align: center;
+  font-size: 24upx;
+  color: #333;
+  background: #fff;
+  border: 1upx solid #e5e5e5;
+  border-radius: 12upx;
+
+  &.active {
+    color: #FF4D6D;
+    border-color: #FF4D6D;
+    background: rgba(255, 77, 109, 0.06);
+  }
+}
+
+.option-grid.cols-4 .option-item {
+  width: calc(25% - 16upx);
+  font-size: 22upx;
+  padding: 16upx 4upx;
+}
+
+.option-empty {
+  width: 100%;
+  padding: 20upx 0;
+  text-align: center;
+  font-size: 24upx;
+  color: #999;
+}
+
+.custom-price-row {
+  display: flex;
+  align-items: center;
+  margin-top: 8upx;
+  padding: 0 16upx;
+  height: 72upx;
+  border: 1upx solid #e5e5e5;
+  border-radius: 12upx;
+  background: #fff;
+}
+
+.price-input {
+  flex: 1;
+  height: 100%;
+  font-size: 26upx;
+  text-align: center;
+}
+
+.price-dash {
+  margin: 0 12upx;
+  color: #999;
+}
+
+.filter-scroll-space {
+  height: 20upx;
+}
+
+.filter-footer {
+  flex-shrink: 0;
+  padding: 20upx 30upx;
+  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
+  background: #fff;
+}
+
+.confirm-btn {
+  height: 88upx;
+  line-height: 88upx;
+  text-align: center;
+  font-size: 32upx;
+  font-weight: 600;
+  color: #fff;
+  background: linear-gradient(135deg, #FF8FA3, #FF4D6D);
+  border-radius: 44upx;
+}
+</style>