Przeglądaj źródła

【功能完善】积分商城

Xiao_123 9 miesięcy temu
rodzic
commit
0b4e70183f

+ 24 - 2
pages.json

@@ -120,6 +120,17 @@
 						"group": "商品"
 					}
 				},
+                {
+                    "path": "point",
+                    "style": {
+                      "navigationBarTitleText": "积分商品"
+                    },
+                    "meta": {
+                      "sync": true,
+                      "title": "积分商品",
+                      "group": "商品"
+                    }
+                },
 				{
 					"path": "list",
 					"style": {
@@ -641,13 +652,24 @@
 						"title": "秒杀活动",
 						"group": "营销活动"
 					}
-				}
+				},
+                {
+                  "path": "point/list",
+                  "style": {
+                    "navigationBarTitleText": "积分商城"
+                  },
+                  "meta": {
+                    "sync": true,
+                    "title": "积分商城",
+                    "group": "营销活动"
+                  }
+                }
 			]
 		}
 	],
 	"globalStyle": {
 		"navigationBarTextStyle": "black",
-		"navigationBarTitleText": "门墩儿甄选商城",
+		"navigationBarTitleText": "芋道商城",
 		"navigationBarBackgroundColor": "#FFFFFF",
 		"backgroundColor": "#FFFFFF",
 		"navigationStyle": "custom"

+ 76 - 0
pages/activity/point/list.vue

@@ -0,0 +1,76 @@
+<!-- 积分商城:商品列表  -->
+<template>
+  <s-layout title="积分商城" navbar="normal" :leftWidth="0" :rightWidth="0">
+    <scroll-view
+      class="scroll-box"
+      :style="{ height: pageHeight + 'rpx' }"
+      scroll-y="true"
+      :scroll-with-animation="false"
+      :enable-back-to-top="true"
+    >
+      <s-point-card ref="sPointCardRef" class="ss-p-x-20 ss-m-t-20" />
+      <s-empty
+        v-if="activityTotal === 0"
+        icon="/static/goods-empty.png"
+        text="暂无积分商品"
+      ></s-empty>
+      <uni-load-more
+        v-if="activityTotal > 0"
+        :status="loadStatus"
+        :content-text="{
+          contentdown: '上拉加载更多',
+        }"
+        @tap="loadMore"
+      />
+    </scroll-view>
+  </s-layout>
+</template>
+<script setup>
+  import sheep from '@/sheep';
+  import { onLoad, onReachBottom } from '@dcloudio/uni-app';
+  import { reactive, ref } from 'vue';
+  import PointApi from '@/sheep/api/promotion/point';
+  import SLayout from '@/sheep/components/s-layout/s-layout.vue';
+
+  // 计算页面高度
+  const { safeAreaInsets, safeArea } = sheep.$platform.device;
+  const statusBarHeight = sheep.$platform.device.statusBarHeight * 2;
+  const pageHeight =
+    (safeArea.height + safeAreaInsets.bottom) * 2 + statusBarHeight - sheep.$platform.navbar - 350;
+
+  const sPointCardRef = ref();
+  // 查询活动列表
+  const activityPageParams = reactive({
+    pageNo: 1, // 页码
+    pageSize: 5, // 每页数量
+  });
+
+  const activityTotal = ref(0); // 活动总数
+  const activityCount = ref(0); // 已加载活动数量
+  const loadStatus = ref(''); // 页面加载状态
+  async function getActivityList() {
+    loadStatus.value = 'loading';
+    const { data } = await PointApi.getPointActivityPage(activityPageParams);
+    await sPointCardRef.value.concatActivity(data.list);
+    activityCount.value = sPointCardRef.value.getActivityCount();
+    activityTotal.value = data.total;
+
+    loadStatus.value = activityCount.value < activityTotal.value ? 'more' : 'noMore';
+  }
+
+  // 加载更多
+  function loadMore() {
+    if (loadStatus.value !== 'noMore') {
+      activityPageParams.pageNo += 1;
+      getActivityList();
+    }
+  }
+
+  // 上拉加载更多
+  onReachBottom(() => {
+    loadMore();
+  });
+  onLoad(() => {
+    getActivityList();
+  });
+</script>

+ 480 - 0
pages/goods/point.vue

@@ -0,0 +1,480 @@
+<!-- 秒杀商品详情 -->
+<template>
+  <s-layout :onShareAppMessage="shareInfo" navbar="goods">
+    <!-- 标题栏 -->
+    <detailNavbar />
+    <!-- 骨架屏 -->
+    <detailSkeleton v-if="state.skeletonLoading" />
+    <!-- 下架/售罄提醒 -->
+    <s-empty
+      v-else-if="state.goodsInfo === null || state.goodsInfo.activity_type !== PromotionActivityTypeEnum.POINT.type"
+      text="活动不存在或已结束"
+      icon="/static/soldout-empty.png"
+      showAction
+      actionText="再逛逛"
+      actionUrl="/pages/goods/list"
+    />
+    <block v-else>
+      <view class="detail-swiper-selector">
+        <!-- 商品图轮播 -->
+        <su-swiper
+          class="ss-m-b-14"
+          isPreview
+          :list="state.goodsSwiper"
+          dotStyle="tag"
+          imageMode="widthFix"
+          dotCur="bg-mask-40"
+          :seizeHeight="750"
+        />
+
+        <!-- 价格+标题 -->
+        <view class="title-card detail-card ss-p-y-40 ss-p-x-20">
+          <view class="ss-flex ss-row-between ss-col-center ss-m-b-18">
+            <view class="price-box ss-flex ss-col-bottom">
+              <image
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                class="point-img"
+              ></image>
+              <text class="point-text ss-m-r-16">
+                {{ getShowPrice.point }}
+                {{ !getShowPrice.price || getShowPrice.price === 0 ? '' : `+¥${getShowPrice.price}` }}
+              </text>
+            </view>
+            <view class="sales-text">
+              {{ formatExchange(state.goodsInfo.sales_show_type, state.goodsInfo.sales) }}
+            </view>
+          </view>
+          <view class="origin-price-text ss-m-b-60" v-if="state.goodsInfo.marketPrice">
+            原价:¥{{ fen2yuan(state.selectedSku.marketPrice || state.goodsInfo.marketPrice) }}
+          </view>
+          <view class="title-text ss-line-2 ss-m-b-6">{{ state.goodsInfo.name || '' }}</view>
+          <view class="subtitle-text ss-line-1">{{ state.goodsInfo.introduction }}</view>
+        </view>
+
+        <!-- 功能卡片 -->
+        <view class="detail-cell-card detail-card ss-flex-col">
+          <detail-cell-sku :sku="state.selectedSku" @tap="state.showSelectSku = true" />
+        </view>
+        <!-- 规格与数量弹框 -->
+        <s-select-seckill-sku
+          v-model="state.goodsInfo"
+          :show="state.showSelectSku"
+          :single-limit-count="activity.singleLimitCount"
+          @buy="onBuy"
+          @change="onSkuChange"
+          @close="state.showSelectSku = false"
+        />
+      </view>
+
+      <!-- 评价 -->
+      <detail-comment-card class="detail-comment-selector" :goodsId="state.goodsInfo.id" />
+      <!-- 详情 -->
+      <detail-content-card class="detail-content-selector" :content="state.goodsInfo.description" />
+
+      <!-- 详情tabbar -->
+      <detail-tabbar v-model="state.goodsInfo">
+        <view class="buy-box ss-flex ss-col-center ss-p-r-20">
+          <button
+            class="ss-reset-button origin-price-btn ss-flex-col"
+            v-if="state.goodsInfo.marketPrice"
+            @tap="sheep.$router.go('/pages/goods/index', { id: state.goodsInfo.id })"
+          >
+            <view>
+              <view class="btn-price">{{ fen2yuan(state.goodsInfo.marketPrice) }}</view>
+              <view>原价购买</view>
+            </view>
+          </button>
+          <button
+            class="ss-reset-button btn-box ss-flex-col"
+            @tap="state.showSelectSku = true"
+            :class="
+             state.goodsInfo.stock != 0
+                ? 'check-btn-box'
+                : 'disabled-btn-box'
+            "
+            :disabled="state.goodsInfo.stock === 0"
+          >
+            <view class="price-box ss-flex">
+              <image
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                style="width: 36rpx;height: 36rpx;margin: 0 4rpx;"
+              ></image>
+              <text class="point-text ss-m-r-16">
+                {{ getShowPrice.point }}
+                {{ !getShowPrice.price || getShowPrice.price === 0 ? '' : `+¥${getShowPrice.price}` }}
+              </text>
+            </view>
+            <view v-if="state.goodsInfo.stock === 0">已售罄</view>
+            <view v-else>立即兑换</view>
+          </button>
+        </view>
+      </detail-tabbar>
+    </block>
+  </s-layout>
+</template>
+
+<script setup>
+  import { computed, reactive, ref, unref } from 'vue';
+  import { onLoad, onPageScroll } from '@dcloudio/uni-app';
+  import sheep from '@/sheep';
+  import { isEmpty } from 'lodash-es';
+  import { fen2yuan, formatExchange, formatGoodsSwiper } from '@/sheep/hooks/useGoods';
+  import detailNavbar from './components/detail/detail-navbar.vue';
+  import detailCellSku from './components/detail/detail-cell-sku.vue';
+  import detailTabbar from './components/detail/detail-tabbar.vue';
+  import detailSkeleton from './components/detail/detail-skeleton.vue';
+  import detailCommentCard from './components/detail/detail-comment-card.vue';
+  import detailContentCard from './components/detail/detail-content-card.vue';
+  import SpuApi from '@/sheep/api/product/spu';
+  import { PromotionActivityTypeEnum } from '@/sheep/util/const';
+  import PointApi from '@/sheep/api/promotion/point';
+
+  const headerBg = sheep.$url.css('/static/img/shop/goods/score-bg.png');
+  const btnBg = sheep.$url.css('/static/img/shop/goods/seckill-btn.png');
+  const disabledBtnBg = sheep.$url.css('/static/img/shop/goods/activity-btn-disabled.png');
+  const seckillBg = sheep.$url.css('/static/img/shop/goods/seckill-tip-bg.png');
+  const grouponBg = sheep.$url.css('/static/img/shop/goods/groupon-tip-bg.png');
+
+  onPageScroll(() => {
+  });
+  const state = reactive({
+    skeletonLoading: true,
+    goodsInfo: {},
+    showSelectSku: false,
+    goodsSwiper: [],
+    selectedSku: {},
+    showModel: false,
+    total: 0,
+    price: '',
+  });
+
+  // 规格变更
+  function onSkuChange(e) {
+    state.selectedSku = e;
+  }
+
+  // 立即购买
+  function onBuy(sku) {
+    sheep.$router.go('/pages/order/confirm', {
+      data: JSON.stringify({
+        order_type: 'goods',
+        buy_type: 'point',
+        pointActivityId: activity.value.id,
+        items: [
+          {
+            skuId: sku.id,
+            count: sku.count,
+          },
+        ],
+      }),
+    });
+  }
+
+  // 分享信息
+  // TODO puhui999: 下次 fix
+  const shareInfo = computed(() => {
+    if (isEmpty(unref(activity))) return {};
+    return sheep.$platform.share.getShareInfo(
+      {
+        title: activity.value.name,
+        image: sheep.$url.cdn(state.goodsInfo.picUrl),
+        params: {
+          page: '4',
+          query: activity.value.id,
+        },
+      },
+      {
+        type: 'goods', // 商品海报
+        title: activity.value.name, // 商品标题
+        image: sheep.$url.cdn(state.goodsInfo.picUrl), // 商品主图
+        price: state.goodsInfo.price, // 商品价格
+        marketPrice: state.goodsInfo.marketPrice, // 商品原价
+      },
+    );
+  });
+
+  const activity = ref();
+
+  const getShowPrice = computed(() => {
+    if (!isEmpty(state.selectedSku)) {
+      const sku = state.selectedSku;
+      return {
+        point: sku.point,
+        price: !sku.pointPrice ? '' : fen2yuan(sku.pointPrice),
+      };
+    }
+    return {
+      point: activity.value.point,
+      price: !activity.value.price ? '' : fen2yuan(activity.value.price),
+    };
+  });
+
+  const getShowPriceText = computed(() => {
+    let priceText = `¥${fen2yuan(state.goodsInfo.price)}`;
+    if (!isEmpty(state.selectedSku)) {
+      const sku = state.selectedSku;
+      priceText = `${sku.point}${!sku.pointPrice ? '' : `+¥${fen2yuan(sku.pointPrice)}`}`;
+    }
+    return priceText;
+  });
+
+  // 查询活动
+  const getActivity = async (id) => {
+    const { data } = await PointApi.getPointActivity(id);
+    activity.value = data;
+    // 查询商品
+    await getSpu(data.spuId);
+  };
+
+  // 查询商品
+  const getSpu = async (id) => {
+    const { data } = await SpuApi.getSpuDetail(id);
+    data.activity_type = PromotionActivityTypeEnum.POINT.type;
+    state.goodsInfo = data;
+    state.goodsInfo.stock = Math.min(data.stock, activity.value.stock);
+    // 处理轮播图
+    state.goodsSwiper = formatGoodsSwiper(state.goodsInfo.sliderPicUrls);
+
+    // 价格、库存使用活动的
+    data.skus.forEach((sku) => {
+      const product = activity.value.products.find((product) => product.skuId === sku.id);
+      if (product) {
+        sku.point = product.point;
+        sku.pointPrice = product.price;
+        sku.stock = Math.min(sku.stock, product.stock);
+        // 设置限购数量
+        sku.limitCount = product.count;
+      } else {
+        // 找不到可能是没配置
+        sku.stock = 0;
+      }
+    });
+
+    state.skeletonLoading = false;
+  };
+
+  onLoad((options) => {
+    // 非法参数
+    if (!options.id) {
+      state.goodsInfo = null;
+      return;
+    }
+
+    // 查询活动
+    getActivity(options.id);
+  });
+</script>
+
+<style lang="scss" scoped>
+  .disabled-btn-box[disabled] {
+    background-color: transparent;
+  }
+
+  .detail-card {
+    background-color: $white;
+    margin: 14rpx 20rpx;
+    border-radius: 10rpx;
+    overflow: hidden;
+  }
+
+  // 价格标题卡片
+  .title-card {
+    width: 710rpx;
+    box-sizing: border-box;
+    background-size: 100% 100%;
+    border-radius: 10rpx;
+    background-image: v-bind(headerBg);
+    background-repeat: no-repeat;
+
+    .price-box {
+      .point-img {
+        width: 36rpx;
+        height: 36rpx;
+        margin: 0 4rpx;
+      }
+
+      .point-text {
+        font-size: 42rpx;
+        font-weight: 500;
+        color: #ff3000;
+        line-height: 36rpx;
+        font-family: OPPOSANS;
+      }
+
+      .price-text {
+        font-size: 42rpx;
+        font-weight: 500;
+        color: #ff3000;
+        line-height: 36rpx;
+        font-family: OPPOSANS;
+      }
+    }
+
+    .origin-price-text {
+      font-size: 26rpx;
+      font-weight: 400;
+      text-decoration: line-through;
+      color: $gray-c;
+      font-family: OPPOSANS;
+    }
+
+    .sales-text {
+      font-size: 26rpx;
+      font-weight: 500;
+      color: $gray-c;
+    }
+
+    .discounts-box {
+      .discounts-tag {
+        padding: 4rpx 10rpx;
+        font-size: 24rpx;
+        font-weight: 500;
+        border-radius: 4rpx;
+        color: var(--ui-BG-Main);
+        // background: rgba(#2aae67, 0.05);
+        background: var(--ui-BG-Main-tag);
+      }
+
+      .discounts-title {
+        font-size: 24rpx;
+        font-weight: 500;
+        color: var(--ui-BG-Main);
+        line-height: normal;
+      }
+
+      .cicon-forward {
+        color: var(--ui-BG-Main);
+        font-size: 24rpx;
+        line-height: normal;
+        margin-top: 4rpx;
+      }
+    }
+
+    .title-text {
+      font-size: 30rpx;
+      font-weight: bold;
+      line-height: 42rpx;
+    }
+
+    .subtitle-text {
+      font-size: 26rpx;
+      font-weight: 400;
+      color: $dark-9;
+      line-height: 42rpx;
+    }
+  }
+
+  // 购买
+  .buy-box {
+    .check-btn-box {
+      width: 248rpx;
+      height: 80rpx;
+      font-size: 24rpx;
+      font-weight: 600;
+      margin-left: -36rpx;
+      background-image: v-bind(btnBg);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      color: #ffffff;
+      line-height: normal;
+      border-radius: 0px 40rpx 40rpx 0px;
+    }
+
+    .disabled-btn-box {
+      width: 248rpx;
+      height: 80rpx;
+      font-size: 24rpx;
+      font-weight: 600;
+      margin-left: -36rpx;
+      background-image: v-bind(disabledBtnBg);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      color: #999999;
+      line-height: normal;
+      border-radius: 0px 40rpx 40rpx 0px;
+    }
+
+    .btn-price {
+      font-family: OPPOSANS;
+
+      &::before {
+        content: '¥';
+      }
+    }
+
+    .origin-price-btn {
+      width: 236rpx;
+      height: 80rpx;
+      background: rgba(#ff5651, 0.1);
+      color: #ff6000;
+      border-radius: 40rpx 0px 0px 40rpx;
+      line-height: normal;
+      font-size: 24rpx;
+      font-weight: 500;
+
+      .no-original {
+        font-size: 28rpx;
+      }
+
+      .btn-title {
+        font-size: 28rpx;
+      }
+    }
+  }
+
+  //秒杀卡片
+  .seckill-box {
+    background: v-bind(seckillBg) no-repeat;
+    background-size: 100% 100%;
+  }
+
+  .groupon-box {
+    background: v-bind(grouponBg) no-repeat;
+    background-size: 100% 100%;
+  }
+
+  //活动卡片
+  .activity-box {
+    width: 100%;
+    height: 80rpx;
+    box-sizing: border-box;
+    margin-bottom: 10rpx;
+
+    .activity-title {
+      font-size: 26rpx;
+      font-weight: 500;
+      color: #ffffff;
+      line-height: 42rpx;
+
+      .activity-icon {
+        width: 38rpx;
+        height: 38rpx;
+      }
+    }
+
+    .activity-go {
+      width: 70rpx;
+      height: 32rpx;
+      background: #ffffff;
+      border-radius: 16rpx;
+      font-weight: 500;
+      color: #ff6000;
+      font-size: 24rpx;
+      line-height: normal;
+    }
+  }
+
+  .model-box {
+    .title {
+      font-size: 36rpx;
+      font-weight: bold;
+      color: #333333;
+    }
+
+    .subtitle {
+      font-size: 26rpx;
+      font-weight: 500;
+      color: #333333;
+    }
+  }
+</style>

+ 9 - 10
pages/order/pickUpVerify.vue

@@ -19,15 +19,15 @@
       <view class="num">{{ orderInfo.pickUpVerifyCode }}</view>
       <view class="rules">
         <!-- TODO puhui999: 需要后端放回:使用 receiveTime 即可 -->
-<!--        <view class="item">-->
-<!--          <view class="rulesTitle flex flex-wrap align-center">-->
-<!--            核销时间-->
-<!--          </view>-->
-<!--          <view class="info">-->
-<!--            每日:-->
-<!--            <text class="time">2020-2-+52</text>-->
-<!--          </view>-->
-<!--        </view>-->
+        <view class="item">
+          <view class="rulesTitle flex flex-wrap align-center">
+            核销时间
+          </view>
+          <view class="info">
+            每日:
+            <text class="time">2020-2-+52</text>
+          </view>
+        </view>
         <view class="item">
           <view class="rulesTitle flex flex-wrap align-center">
             <text class="iconfont icon-shuoming1"></text>
@@ -138,7 +138,6 @@
 </script>
 
 <style scoped lang="scss">
-  // TODO puhui999: 样式需要调整有 bug
   .borRadius14 {
     border-radius: 14rpx !important;
   }

+ 30 - 0
sheep/api/promotion/point.js

@@ -0,0 +1,30 @@
+import request from '@/sheep/request';
+
+const PointApi = {
+  // 获得积分商城活动分页
+  getPointActivityPage: (params) => {
+    return request({ url: 'promotion/point-activity/page', method: 'GET', params });
+  },
+
+  // 获得积分商城活动列表,基于活动编号数组
+  getPointActivityListByIds: (ids) => {
+    return request({
+      url: '/promotion/point-activity/list-by-ids',
+      method: 'GET',
+      params: {
+        ids,
+      },
+    });
+  },
+
+  // 获得积分商城活动明细
+  getPointActivity: (id) => {
+    return request({
+      url: 'promotion/point-activity/get-detail',
+      method: 'GET',
+      params: { id },
+    });
+  },
+};
+
+export default PointApi;

+ 2 - 0
sheep/components/s-block-item/s-block-item.vue

@@ -39,6 +39,8 @@
     <s-groupon-block v-if="type === 'PromotionCombination'" :data="data" :styles="styles" />
     <!-- 营销组件:秒杀 -->
     <s-seckill-block v-if="type === 'PromotionSeckill'" :data="data" :styles="styles" />
+    <!-- 营销组件:积分商城(模式不一样,无法适配) -->
+    <s-point-block v-if="type === 'PromotionPoint'" :data="data" :styles="styles" />
     <!-- 营销组件:小程序直播(暂时没有这个功能) -->
     <s-live-block v-if="type === 'MpLive'" :data="data" :styles="styles" />
     <!-- 营销组件:优惠券 -->

+ 10 - 2
sheep/components/s-goods-item/s-goods-item.vue

@@ -28,6 +28,14 @@
             >
               ¥{{ fen2yuan(price) }}
             </view>
+            <view v-if="point && Number(price) > 0">+</view>
+            <view class="price-text ss-flex ss-col-center" v-if="point">
+              <image
+                :src="sheep.$url.static('/static/img/shop/goods/score1.svg')"
+                class="point-img"
+              ></image>
+              <view>{{ point }}</view>
+            </view>
             <view v-if="num" class="total-text ss-flex ss-col-center">x {{ num }}</view>
             <slot name="priceSuffix"></slot>
           </view>
@@ -88,7 +96,7 @@
       type: [String, Number],
       default: 0,
     },
-    score: {
+    point: {
       type: [String, Number],
       default: '',
     },
@@ -113,7 +121,7 @@
 </script>
 
 <style lang="scss" scoped>
-  .score-img {
+  .point-img {
     width: 36rpx;
     height: 36rpx;
     margin: 0 4rpx;

+ 328 - 0
sheep/components/s-point-block/s-point-block.vue

@@ -0,0 +1,328 @@
+<!-- 装修商品组件:【积分商城】商品卡片 -->
+<template>
+  <!-- 商品卡片 -->
+  <view>
+    <!-- 布局1. 单列大图(上图,下内容)-->
+    <view
+      v-if="layoutType === LayoutTypeEnum.ONE_COL_BIG_IMG && state.spuList.length"
+      class="goods-sl-box"
+    >
+      <view
+        class="goods-box"
+        v-for="item in state.spuList"
+        :key="item.id"
+        :style="[{ marginBottom: data.space * 2 + 'rpx' }]"
+      >
+        <s-goods-column
+          class=""
+          size="sl"
+          :goodsFields="data.fields"
+          :tagStyle="data.badge"
+          :data="item"
+          :titleColor="data.fields.name?.color"
+          :subTitleColor="data.fields.introduction.color"
+          :topRadius="data.borderRadiusTop"
+          :bottomRadius="data.borderRadiusBottom"
+          @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ btnBuy.type === 'text' ? btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局2. 单列小图(左图,右内容) -->
+    <view
+      v-if="layoutType === LayoutTypeEnum.ONE_COL_SMALL_IMG && state.spuList.length"
+      class="goods-lg-box"
+    >
+      <view
+        class="goods-box"
+        :style="[{ marginBottom: data.space + 'px' }]"
+        v-for="item in state.spuList"
+        :key="item.id"
+      >
+        <s-goods-column
+          class="goods-card"
+          size="lg"
+          :goodsFields="data.fields"
+          :data="item"
+          :tagStyle="data.badge"
+          :titleColor="data.fields.name?.color"
+          :subTitleColor="data.fields.introduction.color"
+          :topRadius="data.borderRadiusTop"
+          :bottomRadius="data.borderRadiusBottom"
+          @tap="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ btnBuy.type === 'text' ? btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局3. 双列(每一列:上图,下内容)-->
+    <view
+      v-if="layoutType === LayoutTypeEnum.TWO_COL && state.spuList.length"
+      class="goods-md-wrap ss-flex ss-flex-wrap ss-col-top"
+    >
+      <view class="goods-list-box">
+        <view
+          class="left-list"
+          :style="[{ paddingRight: data.space + 'rpx', marginBottom: data.space + 'px' }]"
+          v-for="item in state.leftSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="data.fields"
+            :tagStyle="data.badge"
+            :data="item"
+            :titleColor="data.fields.name?.color"
+            :subTitleColor="data.fields.introduction.color"
+            :topRadius="data.borderRadiusTop"
+            :bottomRadius="data.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'left')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ btnBuy.type === 'text' ? btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+      <view class="goods-list-box">
+        <view
+          class="right-list"
+          :style="[{ paddingLeft: data.space + 'rpx', marginBottom: data.space + 'px' }]"
+          v-for="item in state.rightSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="data.fields"
+            :tagStyle="data.badge"
+            :data="item"
+            :titleColor="data.fields.name?.color"
+            :subTitleColor="data.fields.introduction.color"
+            :topRadius="data.borderRadiusTop"
+            :bottomRadius="data.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'right')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ btnBuy.type === 'text' ? btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 商品卡片
+   */
+  import { computed, onMounted, reactive, ref } from 'vue';
+  import sheep from '@/sheep';
+  import SpuApi from '@/sheep/api/product/spu';
+  import PointApi from '@/sheep/api/promotion/point';
+  import { PromotionActivityTypeEnum } from '@/sheep/util/const';
+
+  // 布局类型
+  const LayoutTypeEnum = {
+    // 单列大图
+    ONE_COL_BIG_IMG: 'oneColBigImg',
+    // 双列
+    TWO_COL: 'twoCol',
+    // 单列小图
+    ONE_COL_SMALL_IMG: 'oneColSmallImg',
+  };
+
+  const state = reactive({
+    spuList: [],
+    leftSpuList: [],
+    rightSpuList: [],
+  });
+  const props = defineProps({
+    data: {
+      type: Object,
+      default() {},
+    },
+    styles: {
+      type: Object,
+      default() {},
+    },
+  });
+
+  const { layoutType, btnBuy, activityIds } = props.data || {};
+  const { marginLeft, marginRight } = props.styles || {};
+
+  // 购买按钮样式
+  const buyStyle = computed(() => {
+    if (btnBuy.type === 'text') {
+      // 文字按钮:线性渐变背景颜色
+      return {
+        background: `linear-gradient(to right, ${btnBuy.bgBeginColor}, ${btnBuy.bgEndColor})`,
+      };
+    }
+    if (btnBuy.type === 'img') {
+      // 图片按钮
+      return {
+        width: '54rpx',
+        height: '54rpx',
+        background: `url(${sheep.$url.cdn(btnBuy.imgUrl)}) no-repeat`,
+        backgroundSize: '100% 100%',
+      };
+    }
+  });
+
+  //region 商品瀑布流布局
+  // 下一个要处理的商品索引
+  let count = 0;
+  // 左列的高度
+  let leftHeight = 0;
+  // 右列的高度
+  let rightHeight = 0;
+
+  /**
+   * 计算商品在左列还是右列
+   * @param height 商品的高度
+   * @param where 添加到哪一列
+   */
+  function calculateGoodsColumn(height = 0, where = 'left') {
+    // 处理完
+    if (!state.spuList[count]) return;
+    // 增加列的高度
+    if (where === 'left') leftHeight += height;
+    if (where === 'right') rightHeight += height;
+    // 添加到矮的一列
+    if (leftHeight <= rightHeight) {
+      state.leftSpuList.push(state.spuList[count]);
+    } else {
+      state.rightSpuList.push(state.spuList[count]);
+    }
+    // 计数
+    count++;
+  }
+
+  //endregion
+
+  /**
+   * 根据商品编号列表,获取商品列表
+   * @param ids 商品编号列表
+   * @return {Promise<undefined>} 商品列表
+   */
+  async function getPointActivityDetailList(ids) {
+    const { data } = await PointApi.getPointActivityListByIds(ids);
+    return data;
+  }
+
+  /**
+   * 根据商品编号,获取商品详情
+   * @param ids 商品编号列表
+   * @return {Promise<undefined>} 商品列表
+   */
+  async function getSpuDetail(ids) {
+    const { data: spu } = await SpuApi.getSpuDetail(ids);
+    return spu;
+  }
+
+  // 初始化
+  onMounted(async () => {
+    // 加载活动列表
+    const activityList = await getPointActivityDetailList(activityIds.join(','));
+    // 循环获取活动商品SPU详情并添加到spuList
+    for (const activity of activityList) {
+      state.spuList.push(await getSpuDetail(activity.spuId));
+    }
+
+    // 循环活动列表
+    activityList.forEach((activity) => {
+      // 查找对应的 spu 并更新价格
+      const spu = state.spuList.find((spu) => activity.spuId === spu.id);
+      if (spu) {
+        spu.pointStock = activity.stock
+        spu.pointTotalStock = activity.totalStock
+        spu.point = activity.point
+        spu.pointPrice = activity.price
+        // 赋值活动ID,为了点击跳转详情页
+        spu.activityId = activity.id;
+        // 赋值活动类型
+        spu.activityType = PromotionActivityTypeEnum.POINT.type;
+      }
+    });
+
+    // 只有双列布局时需要
+    if (layoutType === LayoutTypeEnum.TWO_COL) {
+      // 分列
+      calculateGoodsColumn();
+    }
+  });
+</script>
+
+<style lang="scss" scoped>
+  .goods-md-wrap {
+    width: 100%;
+  }
+
+  .goods-list-box {
+    width: 50%;
+    box-sizing: border-box;
+
+    .left-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+
+    .right-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+
+  .goods-box {
+    &:nth-last-of-type(1) {
+      margin-bottom: 0 !important;
+    }
+  }
+
+  .goods-md-box,
+  .goods-sl-box,
+  .goods-lg-box {
+    position: relative;
+
+    .cart-btn {
+      position: absolute;
+      bottom: 18rpx;
+      right: 20rpx;
+      z-index: 11;
+      height: 50rpx;
+      line-height: 50rpx;
+      padding: 0 20rpx;
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      color: #fff;
+    }
+  }
+</style>

+ 369 - 0
sheep/components/s-point-card/s-point-card.vue

@@ -0,0 +1,369 @@
+<!-- 装修商品组件:【积分商城】商品卡片 -->
+<template>
+  <!-- 商品卡片 -->
+  <view>
+    <!-- 布局1. 单列大图(上图,下内容)-->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.ONE_COL_BIG_IMG && state.spuList.length"
+      class="goods-sl-box"
+    >
+      <view
+        class="goods-box"
+        v-for="item in state.spuList"
+        :key="item.id"
+        :style="[{ marginBottom: state.property.space * 2 + 'rpx' }]"
+      >
+        <s-goods-column
+          class=""
+          size="sl"
+          :goodsFields="state.property.fields"
+          :tagStyle="state.property.badge"
+          :data="item"
+          :titleColor="state.property.fields.name?.color"
+          :subTitleColor="state.property.fields.introduction.color"
+          :topRadius="state.property.borderRadiusTop"
+          :bottomRadius="state.property.borderRadiusBottom"
+          @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局2. 单列小图(左图,右内容) -->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.ONE_COL_SMALL_IMG && state.spuList.length"
+      class="goods-lg-box"
+    >
+      <view
+        class="goods-box"
+        :style="[{ marginBottom: state.property.space + 'px' }]"
+        v-for="item in state.spuList"
+        :key="item.id"
+      >
+        <s-goods-column
+          class="goods-card"
+          size="lg"
+          :goodsFields="state.property.fields"
+          :data="item"
+          :tagStyle="state.property.badge"
+          :titleColor="state.property.fields.name?.color"
+          :subTitleColor="state.property.fields.introduction.color"
+          :topRadius="state.property.borderRadiusTop"
+          :bottomRadius="state.property.borderRadiusBottom"
+          @tap="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+        >
+          <!-- 购买按钮 -->
+          <template v-slot:cart>
+            <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+              {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+            </button>
+          </template>
+        </s-goods-column>
+      </view>
+    </view>
+
+    <!-- 布局3. 双列(每一列:上图,下内容)-->
+    <view
+      v-if="state.property.layoutType === LayoutTypeEnum.TWO_COL && state.spuList.length"
+      class="goods-md-wrap ss-flex ss-flex-wrap ss-col-top"
+    >
+      <view class="goods-list-box">
+        <view
+          class="left-list"
+          :style="[{ paddingRight: state.property.space + 'rpx', marginBottom: state.property.space + 'px' }]"
+          v-for="item in state.leftSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="state.property.fields"
+            :tagStyle="state.property.badge"
+            :data="item"
+            :titleColor="state.property.fields.name?.color"
+            :subTitleColor="state.property.fields.introduction.color"
+            :topRadius="state.property.borderRadiusTop"
+            :bottomRadius="state.property.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'left')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+      <view class="goods-list-box">
+        <view
+          class="right-list"
+          :style="[{ paddingLeft: state.property.space + 'rpx', marginBottom: state.property.space + 'px' }]"
+          v-for="item in state.rightSpuList"
+          :key="item.id"
+        >
+          <s-goods-column
+            class="goods-md-box"
+            size="md"
+            :goodsFields="state.property.fields"
+            :tagStyle="state.property.badge"
+            :data="item"
+            :titleColor="state.property.fields.name?.color"
+            :subTitleColor="state.property.fields.introduction.color"
+            :topRadius="state.property.borderRadiusTop"
+            :bottomRadius="state.property.borderRadiusBottom"
+            :titleWidth="330 - marginLeft - marginRight"
+            @click="sheep.$router.go('/pages/goods/point', { id: item.activityId })"
+            @getHeight="calculateGoodsColumn($event, 'right')"
+          >
+            <!-- 购买按钮 -->
+            <template v-slot:cart>
+              <button class="ss-reset-button cart-btn" :style="[buyStyle]">
+                {{ state.property.btnBuy.type === 'text' ? state.property.btnBuy.text : '' }}
+              </button>
+            </template>
+          </s-goods-column>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup>
+  /**
+   * 商品卡片
+   */
+  import { computed, nextTick, onMounted, reactive, watch } from 'vue';
+  import sheep from '@/sheep';
+  import SpuApi from '@/sheep/api/product/spu';
+  import { PromotionActivityTypeEnum } from '@/sheep/util/const';
+  import { isEmpty } from '@/sheep/helper/utils';
+
+  // 布局类型
+  const LayoutTypeEnum = {
+    // 单列大图
+    ONE_COL_BIG_IMG: 'oneColBigImg',
+    // 双列
+    TWO_COL: 'twoCol',
+    // 单列小图
+    ONE_COL_SMALL_IMG: 'oneColSmallImg',
+  };
+
+  const state = reactive({
+    spuList: [],
+    leftSpuList: [],
+    rightSpuList: [],
+    property: {
+      'layoutType': 'oneColBigImg',
+      'fields': {
+        'name': {
+          'show': true,
+          'color': '#000',
+        },
+        'introduction': {
+          'show': true,
+          'color': '#999',
+        },
+        'price': {
+          'show': true,
+          'color': '#ff3000',
+        },
+        'marketPrice': {
+          'show': true,
+          'color': '#c4c4c4',
+        },
+        'salesCount': {
+          'show': true,
+          'color': '#c4c4c4',
+        },
+        'stock': {
+          'show': true,
+          'color': '#c4c4c4',
+        },
+      },
+      'badge': {
+        'show': false,
+        'imgUrl': '',
+      },
+      'btnBuy': {
+        'type': 'text',
+        'text': '立即兑换',
+        'bgBeginColor': '#FF6000',
+        'bgEndColor': '#FE832A',
+        'imgUrl': '',
+      },
+      'borderRadiusTop': 8,
+      'borderRadiusBottom': 8,
+      'space': 8,
+      'style': {
+        'bgType': 'color',
+        'bgColor': '',
+        'marginLeft': 8,
+        'marginRight': 8,
+        'marginBottom': 8,
+      },
+    },
+  });
+  const props = defineProps({
+    property: {
+      type: Object,
+      default: () => ({}),
+    },
+  });
+  // 动态更新 property
+  watch(() => props.property, (newVal) => {
+    state.property = { ...state.property, ...newVal };
+  }, { immediate: true, deep: true });
+  const { marginLeft, marginRight } = state.property.styles || {};
+
+  // 购买按钮样式
+  const buyStyle = computed(() => {
+    if (state.property.btnBuy.type === 'text') {
+      // 文字按钮:线性渐变背景颜色
+      return {
+        background: `linear-gradient(to right, ${state.property.btnBuy.bgBeginColor}, ${state.property.btnBuy.bgEndColor})`,
+      };
+    }
+    if (state.property.btnBuy.type === 'img') {
+      // 图片按钮
+      return {
+        width: '54rpx',
+        height: '54rpx',
+        background: `url(${sheep.$url.cdn(state.property.btnBuy.imgUrl)}) no-repeat`,
+        backgroundSize: '100% 100%',
+      };
+    }
+  });
+
+  //region 商品瀑布流布局
+  // 下一个要处理的商品索引
+  let count = 0;
+  // 左列的高度
+  let leftHeight = 0;
+  // 右列的高度
+  let rightHeight = 0;
+
+  /**
+   * 计算商品在左列还是右列
+   * @param height 商品的高度
+   * @param where 添加到哪一列
+   */
+  function calculateGoodsColumn(height = 0, where = 'left') {
+    // 处理完
+    if (!state.spuList[count]) return;
+    // 增加列的高度
+    if (where === 'left') leftHeight += height;
+    if (where === 'right') rightHeight += height;
+    // 添加到矮的一列
+    if (leftHeight <= rightHeight) {
+      state.leftSpuList.push(state.spuList[count]);
+    } else {
+      state.rightSpuList.push(state.spuList[count]);
+    }
+    // 计数
+    count++;
+  }
+
+  //endregion
+
+  /**
+   * 根据商品编号,获取商品详情
+   * @param ids 商品编号列表
+   * @return {Promise<undefined>} 商品列表
+   */
+  async function getSpuDetail(ids) {
+    const { data: spu } = await SpuApi.getSpuDetail(ids);
+    return spu;
+  }
+
+  async function concatActivity(list) {
+    if (isEmpty(list)) {
+      return;
+    }
+    // 循环获取活动商品SPU详情并添加到spuList
+    for (const activity of list) {
+      state.spuList.push(await getSpuDetail(activity.spuId));
+    }
+
+    // 循环活动列表
+    list.forEach((activity) => {
+      // 查找对应的 spu 并更新价格
+      const spu = state.spuList.find((spu) => activity.spuId === spu.id);
+      if (spu) {
+        spu.pointStock = activity.stock;
+        spu.pointTotalStock = activity.totalStock;
+        spu.point = activity.point;
+        spu.pointPrice = activity.price;
+        // 赋值活动ID,为了点击跳转详情页
+        spu.activityId = activity.id;
+        // 赋值活动类型
+        spu.activityType = PromotionActivityTypeEnum.POINT.type;
+      }
+    });
+    // 只有双列布局时需要
+    if (state.property.layoutType === LayoutTypeEnum.TWO_COL) {
+      // 分列
+      calculateGoodsColumn();
+    }
+  }
+  function getActivityCount() {
+    return state.spuList.length;
+  }
+  defineExpose({ concatActivity,getActivityCount,calculateGoodsColumn });
+</script>
+
+<style lang="scss" scoped>
+  .goods-md-wrap {
+    width: 100%;
+  }
+
+  .goods-list-box {
+    width: 50%;
+    box-sizing: border-box;
+
+    .left-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+
+    .right-list {
+      &:nth-last-child(1) {
+        margin-bottom: 0 !important;
+      }
+    }
+  }
+
+  .goods-box {
+    &:nth-last-of-type(1) {
+      margin-bottom: 0 !important;
+    }
+  }
+
+  .goods-md-box,
+  .goods-sl-box,
+  .goods-lg-box {
+    position: relative;
+
+    .cart-btn {
+      position: absolute;
+      bottom: 18rpx;
+      right: 20rpx;
+      z-index: 11;
+      height: 50rpx;
+      line-height: 50rpx;
+      padding: 0 20rpx;
+      border-radius: 25rpx;
+      font-size: 24rpx;
+      color: #fff;
+    }
+  }
+</style>