Browse Source

feat(group-buy): 完善团购配置与商城开团入口

- 后台团购商品支持按多规格拆分配置,并固定活动结束后的自动退款规则

- 商城新增团购详情页、团购接口、倒计时和参团头像组件,首页与更多列表跳转到团购开团流程

- 订单列表和详情展示拼团状态,并避免活动商品购物车行键与普通商品合并
shizhongqi 1 week ago
parent
commit
4185876e30

+ 12 - 4
hdApp/src/admin/homePageConfig/groupBuy.vue

@@ -212,7 +212,10 @@ export default {
       })
     },
     normalizeForm(data) {
+      // autoRefund 统一默认开启;specName 仅草稿/编辑页区分多规格展示用
       const goods = Array.isArray(data.goods) ? data.goods.map((g) => ({
+        // 活动商品版本 id,保存时原样回传给后端做版本判定
+        id: g.id || g.activityGoodsId || 0,
         goodsId: g.goodsId || 0,
         price: g.price || '',
         stock: g.stock || '',
@@ -220,12 +223,13 @@ export default {
         groupSize: [2, 3, 5].indexOf(parseInt(g.groupSize, 10)) > -1 ? parseInt(g.groupSize, 10) : 3,
         virtualGroup: g.virtualGroup == 1 ? 1 : 0,
         virtualMinutes: g.virtualMinutes || '',
-        autoRefund: g.autoRefund == 1 ? 1 : 0,
+        autoRefund: 1,
         status: g.status == 1 ? 1 : 0,
         name: g.name || '',
         cover: g.cover || '',
         originPrice: g.originPrice || 0,
-        realStock: g.realStock !== undefined ? g.realStock : (g.stock || 0)
+        realStock: g.realStock !== undefined ? g.realStock : (g.stock || 0),
+        specName: g.specName || ''
       })) : []
       const startTime = parseInt(data.startTime, 10) || 0
       const endTime = parseInt(data.endTime, 10) || 0
@@ -342,7 +346,10 @@ export default {
         this.$msg('活动说明不能超过500字')
         return
       }
+      // autoRefund 统一固定为 1:活动结束未拼成自动退款、拼成自动退差价
+      // 透传 id(活动商品版本 id),便于后端判断更新现有版本还是新建版本
       const goodsPayload = (this.form.goods || []).map((g) => ({
+        id: parseInt(g.id, 10) || 0,
         goodsId: g.goodsId,
         price: parseFloat(g.price) || 0,
         stock: parseInt(g.stock, 10) || 0,
@@ -350,11 +357,12 @@ export default {
         groupSize: parseInt(g.groupSize, 10) || 3,
         virtualGroup: g.virtualGroup == 1 ? 1 : 0,
         virtualMinutes: parseInt(g.virtualMinutes, 10) || 0,
-        autoRefund: g.autoRefund == 1 ? 1 : 0,
+        autoRefund: 1,
         status: g.status == 1 ? 1 : 0,
         name: g.name || '',
         cover: g.cover || '',
-        originPrice: parseFloat(g.originPrice) || 0
+        originPrice: parseFloat(g.originPrice) || 0,
+        specName: g.specName || ''
       }))
       this.saving = true
       // layoutCols/displayCount 由后端按商品数量与 expand 自动推导,前端不再提交

+ 293 - 108
hdApp/src/admin/homePageConfig/groupBuyGoodsEdit.vue

@@ -1,29 +1,47 @@
 <!--
   团购商品编辑页
   从团购配置页跳转,单选活动商品并填写团购价/成团人数/库存等;通过 groupBuyDraft 与列表页同步。
+  单规格:一套表单;多规格:按规格数动态生成多套表单,保存时每规格各写一条团购商品(goodsId 用子规格 id)。
+  自动退款不再可配,统一默认开启(活动结束未拼成自动退款、拼成自动退差价)。
 -->
 <template>
   <view class="page-container">
     <view class="module-com input-line-wrap form-card">
       <tui-list-cell class="line-cell" :arrow="true" :hover="true" @click="selectGoods">
         <view class="tui-title required">活动商品</view>
-        <view class="tui-input picker-text" :class="{ placeholder: !goodsForm.name }">
-          {{ goodsForm.name || '请选择活动商品' }}
+        <view class="tui-input picker-text" :class="{ placeholder: !goodsBase.name }">
+          {{ goodsBase.name || '请选择活动商品' }}
         </view>
       </tui-list-cell>
 
-      <tui-list-cell v-if="goodsForm.cover" class="line-cell" :hover="false">
-        <image class="preview-cover" :src="imgFullUrl(goodsForm.cover)" mode="aspectFill" />
+      <tui-list-cell v-if="goodsBase.cover" class="line-cell" :hover="false" :last="!specForms.length">
+        <image class="preview-cover" :src="imgFullUrl(goodsBase.cover)" mode="aspectFill" />
       </tui-list-cell>
+    </view>
+
+    <!-- 单规格一套表单;多规格按规格数动态多套 -->
+    <!-- 小程序端:v-for 内不用别名 v-model,事件参数用 data-* 传递避免 data-event-opts 编译失败 -->
+    <view
+      v-for="(item, sIndex) in specForms"
+      :key="sIndex"
+      class="module-com input-line-wrap form-card"
+    >
+      <view v-if="isMultiSpec" class="spec-head">
+        <text class="spec-head-label">规格{{ sIndex + 1 }}</text>
+        <text class="spec-head-name">{{ item.specName || '未命名规格' }}</text>
+      </view>
 
       <tui-list-cell class="line-cell" :hover="false">
         <view class="tui-title required">团购价格</view>
         <input
-          v-model="goodsForm.price"
+          :value="item.price"
           class="tui-input"
           placeholder-class="phcolor"
           placeholder="请输入团购价格"
           type="digit"
+          :data-index="sIndex"
+          data-field="price"
+          @input="onSpecInput"
         />
       </tui-list-cell>
 
@@ -34,8 +52,10 @@
             v-for="n in groupSizeOptions"
             :key="n"
             class="segment-tab"
-            :class="{ active: goodsForm.groupSize === n }"
-            @click="goodsForm.groupSize = n"
+            :class="{ active: item.groupSize === n }"
+            :data-index="sIndex"
+            :data-size="n"
+            @click="onGroupSizeClick"
           >{{ n }}人</view>
         </view>
       </view>
@@ -43,57 +63,70 @@
       <tui-list-cell class="line-cell" :hover="false">
         <view class="tui-title required">团购库存</view>
         <input
-          v-model="goodsForm.stock"
+          :value="item.stock"
           class="tui-input"
           placeholder-class="phcolor"
-          :placeholder="stockPlaceholder"
+          :placeholder="item.realStock > 0 ? '不超过实际库存' + item.realStock : '请输入团购库存'"
           type="number"
+          :data-index="sIndex"
+          data-field="stock"
+          @input="onSpecInput"
         />
       </tui-list-cell>
 
       <tui-list-cell class="line-cell" :hover="false">
         <view class="tui-title required">单人限购</view>
         <input
-          v-model="goodsForm.limit"
+          :value="item.limit"
           class="tui-input"
           placeholder-class="phcolor"
           placeholder="请输入单人限购数量"
           type="number"
+          :data-index="sIndex"
+          data-field="limit"
+          @input="onSpecInput"
         />
       </tui-list-cell>
 
       <tui-list-cell class="line-cell between" :hover="false">
         <view class="tui-title">虚拟成团</view>
         <view class="tui-operate switch-wrap">
-          <switch :checked="goodsForm.virtualGroup == 1" color="#09C567" @change="onVirtualGroupChange" />
+          <switch
+            :checked="item.virtualGroup == 1"
+            color="#09C567"
+            :data-index="sIndex"
+            @change="onVirtualGroupChange"
+          />
         </view>
       </tui-list-cell>
 
-      <tui-list-cell v-if="goodsForm.virtualGroup == 1" class="line-cell" :hover="false">
+      <tui-list-cell v-if="item.virtualGroup == 1" class="line-cell" :hover="false">
         <view class="tui-title required">虚拟成团时间</view>
         <input
-          v-model="goodsForm.virtualMinutes"
+          :value="item.virtualMinutes"
           class="tui-input"
           placeholder-class="phcolor"
           placeholder="请输入分钟数"
           type="number"
+          :data-index="sIndex"
+          data-field="virtualMinutes"
+          @input="onSpecInput"
         />
         <text class="unit-text">分钟</text>
       </tui-list-cell>
 
-      <tui-list-cell class="line-cell between" :hover="false" :last="true">
-        <view class="tui-title">自动退款</view>
-        <view class="tui-operate switch-wrap">
-          <switch :checked="goodsForm.autoRefund == 1" color="#09C567" @change="onAutoRefundChange" />
-        </view>
+      <tui-list-cell class="line-cell" :hover="false">
+        <view class="tui-title">商品原价格</view>
+        <view class="tui-input real-stock-text">{{ item.originPrice > 0 ? item.originPrice : '--' }}</view>
       </tui-list-cell>
-    </view>
 
-    <view class="refund-tip">
-      开启后活动结束未拼成自动退款、拼成自动退差价
+      <tui-list-cell class="line-cell" :hover="false" :last="true">
+        <view class="tui-title">商品实际库存</view>
+        <view class="tui-input real-stock-text">{{ item.realStock > 0 ? item.realStock : '--' }}</view>
+      </tui-list-cell>
     </view>
 
-    <view v-if="goodsForm.realStock > 0" class="tip-text">商品实际库存:{{ goodsForm.realStock }}</view>
+    <view class="refund-tip">活动结束未拼成自动退款、拼成自动退差价</view>
 
     <view class="bottom-bar">
       <button class="admin-button-com big default bottom-btn" @click="cancelFn">取消</button>
@@ -104,24 +137,33 @@
 
 <script>
 import TuiListCell from '@/components/plugin/list-cell'
+import { getGoodsDetail } from '@/api/goods'
 
 const DRAFT_KEY = 'groupBuyDraft'
 const GROUP_SIZE_OPTIONS = [2, 3, 5]
 
-const EMPTY_GOODS = () => ({
+/** 空的商品基础信息(名称/封面等,多规格共用) */
+const EMPTY_BASE = () => ({
+  masterGoodsId: 0,
+  name: '',
+  cover: '',
+  status: 1
+})
+
+/** 空的规格填写表单(单规格时只有一项);autoRefund 统一固定为 1,不再由表单配置 */
+const EMPTY_SPEC_FORM = () => ({
+  id: 0, // 活动商品版本 id,编辑已有记录时回传后端
   goodsId: 0,
+  specName: '',
   price: '',
   stock: '',
   limit: '',
   groupSize: 3,
   virtualGroup: 0,
   virtualMinutes: '',
-  autoRefund: 0,
-  status: 1,
-  name: '',
-  cover: '',
   originPrice: 0,
-  realStock: 0
+  realStock: 0,
+  cover: ''
 })
 
 export default {
@@ -131,18 +173,13 @@ export default {
     return {
       constant: this.$constant,
       goodsIndex: -1,
-      goodsForm: EMPTY_GOODS(),
+      goodsBase: EMPTY_BASE(),
+      // 单规格 1 项;多规格按规格数展开
+      specForms: [EMPTY_SPEC_FORM()],
+      isMultiSpec: false,
       groupSizeOptions: GROUP_SIZE_OPTIONS
     }
   },
-  computed: {
-    stockPlaceholder() {
-      if (this.goodsForm.realStock > 0) {
-        return `不超过实际库存${this.goodsForm.realStock}`
-      }
-      return '请输入团购库存'
-    }
-  },
   onLoad() {
     uni.$on('categoryAdGoodsSelected', this.onGoodsSelected)
   },
@@ -150,7 +187,10 @@ export default {
     uni.$off('categoryAdGoodsSelected', this.onGoodsSelected)
   },
   methods: {
-    /** 解析路由 index,从 groupBuyDraft 加载待编辑商品 */
+    /**
+     * 解析路由 index,从 groupBuyDraft 加载待编辑商品
+     * 编辑态按单条展示(列表中每规格已是独立一条)
+     */
     init() {
       this.goodsIndex = parseInt(this.option && this.option.index, 10)
       if (isNaN(this.goodsIndex)) {
@@ -158,9 +198,33 @@ export default {
       }
       const draft = uni.getStorageSync(DRAFT_KEY) || {}
       if (this.goodsIndex >= 0 && draft.goods && draft.goods[this.goodsIndex]) {
-        this.goodsForm = { ...EMPTY_GOODS(), ...draft.goods[this.goodsIndex] }
+        const item = draft.goods[this.goodsIndex]
+        const groupSize = parseInt(item.groupSize, 10)
+        this.goodsBase = {
+          masterGoodsId: item.goodsId || 0,
+          name: item.name || '',
+          cover: item.cover || '',
+          status: item.status == 1 ? 1 : 0
+        }
+        this.isMultiSpec = false
+        this.specForms = [{
+          id: item.id || item.activityGoodsId || 0,
+          goodsId: item.goodsId || 0,
+          specName: item.specName || '',
+          price: item.price !== undefined && item.price !== null ? item.price : '',
+          stock: item.stock !== undefined && item.stock !== null ? item.stock : '',
+          limit: item.limit !== undefined && item.limit !== null ? item.limit : '',
+          groupSize: GROUP_SIZE_OPTIONS.indexOf(groupSize) > -1 ? groupSize : 3,
+          virtualGroup: item.virtualGroup == 1 ? 1 : 0,
+          virtualMinutes: item.virtualMinutes || '',
+          originPrice: item.originPrice || 0,
+          realStock: item.realStock || 0,
+          cover: item.cover || ''
+        }]
       } else {
-        this.goodsForm = EMPTY_GOODS()
+        this.goodsBase = EMPTY_BASE()
+        this.specForms = [EMPTY_SPEC_FORM()]
+        this.isMultiSpec = false
       }
     },
     imgFullUrl(path) {
@@ -169,91 +233,192 @@ export default {
       const base = (this.constant.imgUrl || '').replace(/\/$/, '')
       return `${base}/${String(path).replace(/^\//, '')}`
     },
+    /**
+     * 规格表单字段输入(小程序端 v-for 内不能直接 v-model 别名)
+     * 下标/字段名通过 data-index、data-field 传入
+     */
+    onSpecInput(e) {
+      const dataset = (e && e.currentTarget && e.currentTarget.dataset) || {}
+      const index = parseInt(dataset.index, 10)
+      const field = dataset.field
+      const value = e && e.detail ? e.detail.value : ''
+      if (isNaN(index) || !field || !this.specForms[index]) return
+      this.$set(this.specForms[index], field, value)
+    },
+    /** 选择成团人数:通过 data-index / data-size 更新对应规格表单 */
+    onGroupSizeClick(e) {
+      const dataset = (e && e.currentTarget && e.currentTarget.dataset) || {}
+      const index = parseInt(dataset.index, 10)
+      const size = parseInt(dataset.size, 10)
+      if (isNaN(index) || !this.specForms[index]) return
+      if (GROUP_SIZE_OPTIONS.indexOf(size) === -1) return
+      this.$set(this.specForms[index], 'groupSize', size)
+    },
+    /** 切换虚拟成团开关;关闭时清空虚拟成团分钟数 */
+    onVirtualGroupChange(e) {
+      const dataset = (e && e.currentTarget && e.currentTarget.dataset) || {}
+      const index = parseInt(dataset.index, 10)
+      if (isNaN(index) || !this.specForms[index]) return
+      const checked = e && e.detail ? !!e.detail.value : false
+      this.$set(this.specForms[index], 'virtualGroup', checked ? 1 : 0)
+      if (!checked) {
+        this.$set(this.specForms[index], 'virtualMinutes', '')
+      }
+    },
+    /** 跳转花束商品单选页(列表为主商品 masterId=0) */
     selectGoods() {
+      const selectedId = this.goodsBase.masterGoodsId || (this.specForms[0] && this.specForms[0].goodsId) || ''
       uni.navigateTo({
-        url: `/admin/goods/ad-goods-select?mode=single&selectedIds=${this.goodsForm.goodsId || ''}`
+        url: `/admin/goods/ad-goods-select?mode=single&selectedIds=${selectedId}`
       })
     },
+    /**
+     * 接收商品选择结果:拉详情判断是否多规格,动态生成填写表单
+     * 多规格时每条子规格用自己的 goodsId/库存/原价
+     */
     onGoodsSelected(payload) {
       const g = (payload.goodsList || [])[0]
       if (!g) return
-      this.goodsForm.goodsId = g.id
-      this.goodsForm.name = g.name || ''
-      this.goodsForm.cover = g.cover || ''
-      this.goodsForm.originPrice = g.price || 0
-      this.goodsForm.realStock = parseInt(g.stock, 10) || 0
-    },
-    onVirtualGroupChange(e) {
-      this.goodsForm.virtualGroup = e.detail.value ? 1 : 0
-      if (!this.goodsForm.virtualGroup) {
-        this.goodsForm.virtualMinutes = ''
-      }
+      this.goodsBase.masterGoodsId = g.id
+      this.goodsBase.name = g.name || ''
+      this.goodsBase.cover = g.cover || ''
+      // 先用列表快照填一套单规格表单,详情返回后再按规格覆盖
+      this.isMultiSpec = false
+      this.specForms = [{
+        ...EMPTY_SPEC_FORM(),
+        goodsId: g.id,
+        originPrice: g.price || 0,
+        realStock: parseInt(g.stock, 10) || 0,
+        cover: g.cover || ''
+      }]
+      this.loadGoodsSpecs(g.id)
     },
-    onAutoRefundChange(e) {
-      this.goodsForm.autoRefund = e.detail.value ? 1 : 0
+    /**
+     * 拉取商品详情,多规格则按 specList 展开多套表单
+     * @param {number} goodsId 主商品 id
+     */
+    loadGoodsSpecs(goodsId) {
+      if (!goodsId) return
+      getGoodsDetail({ id: goodsId }).then((res) => {
+        if (res.code !== 1 || !res.data) return
+        // 若用户已切换到其他商品,忽略过期回调
+        if (parseInt(goodsId, 10) !== parseInt(this.goodsBase.masterGoodsId, 10)) return
+        const data = res.data
+        const cover = data.shortCover || data.cover || this.goodsBase.cover || ''
+        this.goodsBase.name = data.name || this.goodsBase.name
+        this.goodsBase.cover = cover
+        const specList = Array.isArray(data.specList) ? data.specList : []
+        // 启用多规格且存在子规格:一套规格一张填写表单
+        if (Number(data.specEnabled) === 1 && specList.length > 0) {
+          this.isMultiSpec = true
+          this.specForms = specList.map((spec) => ({
+            ...EMPTY_SPEC_FORM(),
+            goodsId: spec.id || 0,
+            specName: spec.specName || '',
+            originPrice: spec.price || 0,
+            realStock: parseInt(spec.stock, 10) || 0,
+            cover: spec.shortCover || spec.cover || cover
+          }))
+          return
+        }
+        this.isMultiSpec = false
+        this.specForms = [{
+          ...EMPTY_SPEC_FORM(),
+          goodsId: data.id || goodsId,
+          originPrice: data.price || 0,
+          realStock: parseInt(data.stock, 10) || 0,
+          cover
+        }]
+      })
     },
     cancelFn() {
       uni.navigateBack()
     },
-    /** 校验后写回 groupBuyDraft 并返回列表页 */
+    /**
+     * 校验全部规格表单后写回 groupBuyDraft
+     * 多规格保存为多条;autoRefund 统一写 1(产品要求默认自动退款)
+     */
     saveFn() {
-      const goodsId = parseInt(this.goodsForm.goodsId, 10) || 0
-      const price = parseFloat(this.goodsForm.price)
-      const stock = parseInt(this.goodsForm.stock, 10)
-      const limit = parseInt(this.goodsForm.limit, 10)
-      const groupSize = parseInt(this.goodsForm.groupSize, 10)
-      const virtualMinutes = parseInt(this.goodsForm.virtualMinutes, 10)
-      if (!goodsId) {
+      if (!this.goodsBase.masterGoodsId && !(this.specForms[0] && this.specForms[0].goodsId)) {
         this.$msg('请选择活动商品')
         return
       }
-      if (!price || price <= 0) {
-        this.$msg('请输入团购价格')
-        return
-      }
-      if (GROUP_SIZE_OPTIONS.indexOf(groupSize) === -1) {
-        this.$msg('请选择成团人数')
-        return
-      }
-      if (!stock || stock <= 0) {
-        this.$msg('请输入团购库存')
-        return
-      }
-      if (this.goodsForm.realStock > 0 && stock > this.goodsForm.realStock) {
-        this.$msg(`团购库存不能超过实际库存(${this.goodsForm.realStock})`)
-        return
-      }
-      if (!limit || limit <= 0) {
-        this.$msg('请输入单人限购')
-        return
-      }
-      if (this.goodsForm.virtualGroup == 1 && (!virtualMinutes || virtualMinutes <= 0)) {
-        this.$msg('请填写虚拟成团时间')
-        return
+      const items = []
+      for (let i = 0; i < this.specForms.length; i++) {
+        const form = this.specForms[i]
+        const label = this.isMultiSpec
+          ? `规格「${form.specName || (i + 1)}」`
+          : ''
+        const goodsId = parseInt(form.goodsId, 10) || 0
+        const price = parseFloat(form.price)
+        const stock = parseInt(form.stock, 10)
+        const limit = parseInt(form.limit, 10)
+        const groupSize = parseInt(form.groupSize, 10)
+        const virtualMinutes = parseInt(form.virtualMinutes, 10)
+        if (!goodsId) {
+          this.$msg(label ? `${label}商品无效,请重新选择` : '请选择活动商品')
+          return
+        }
+        if (!price || price <= 0) {
+          this.$msg(label ? `请输入${label}团购价格` : '请输入团购价格')
+          return
+        }
+        if (GROUP_SIZE_OPTIONS.indexOf(groupSize) === -1) {
+          this.$msg(label ? `请选择${label}成团人数` : '请选择成团人数')
+          return
+        }
+        if (!stock || stock <= 0) {
+          this.$msg(label ? `请输入${label}团购库存` : '请输入团购库存')
+          return
+        }
+        // 团购库存不得超过该规格实际库存
+        if (form.realStock > 0 && stock > form.realStock) {
+          this.$msg(
+            label
+              ? `${label}团购库存不能超过实际库存(${form.realStock})`
+              : `团购库存不能超过实际库存(${form.realStock})`
+          )
+          return
+        }
+        if (!limit || limit <= 0) {
+          this.$msg(label ? `请输入${label}单人限购` : '请输入单人限购')
+          return
+        }
+        if (form.virtualGroup == 1 && (!virtualMinutes || virtualMinutes <= 0)) {
+          this.$msg(label ? `请填写${label}虚拟成团时间` : '请填写虚拟成团时间')
+          return
+        }
+        // 列表展示名带规格后缀,便于区分同商品多规格
+        const displayName = form.specName
+          ? `${this.goodsBase.name}(${form.specName})` : this.goodsBase.name
+        items.push({
+          id: parseInt(form.id, 10) || 0,
+          goodsId,
+          price,
+          stock,
+          limit,
+          groupSize,
+          virtualGroup: form.virtualGroup == 1 ? 1 : 0,
+          virtualMinutes: form.virtualGroup == 1 ? virtualMinutes : 0,
+          // 自动退款统一开启,前端不再提供开关
+          autoRefund: 1,
+          status: this.goodsBase.status == 1 ? 1 : 0,
+          name: displayName,
+          cover: form.cover || this.goodsBase.cover,
+          originPrice: parseFloat(form.originPrice) || 0,
+          realStock: form.realStock || 0,
+          specName: form.specName || ''
+        })
       }
       const draft = uni.getStorageSync(DRAFT_KEY) || { goods: [] }
       if (!Array.isArray(draft.goods)) {
         draft.goods = []
       }
-      const item = {
-        goodsId,
-        price,
-        stock,
-        limit,
-        groupSize,
-        virtualGroup: this.goodsForm.virtualGroup == 1 ? 1 : 0,
-        virtualMinutes: this.goodsForm.virtualGroup == 1 ? virtualMinutes : 0,
-        autoRefund: this.goodsForm.autoRefund == 1 ? 1 : 0,
-        status: this.goodsForm.status == 1 ? 1 : 0,
-        name: this.goodsForm.name,
-        cover: this.goodsForm.cover,
-        originPrice: parseFloat(this.goodsForm.originPrice) || 0,
-        realStock: this.goodsForm.realStock
-      }
+      // 编辑态:用本次结果替换原位置(多规格重选时可能 1 变 N)
       if (this.goodsIndex >= 0) {
-        this.$set(draft.goods, this.goodsIndex, item)
+        draft.goods.splice(this.goodsIndex, 1, ...items)
       } else {
-        draft.goods.push(item)
+        draft.goods.push(...items)
       }
       draft._goodsUpdated = true
       uni.setStorageSync(DRAFT_KEY, draft)
@@ -298,6 +463,27 @@ export default {
   background: #f5f5f5;
 }
 
+.spec-head {
+  @include disFlex(center, flex-start);
+  padding: 24upx 30upx 8upx;
+}
+
+.spec-head-label {
+  flex-shrink: 0;
+  font-size: 26upx;
+  color: $fontColor3;
+  margin-right: 12upx;
+}
+
+.spec-head-name {
+  font-size: 28upx;
+  color: #333;
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .segment-row {
   padding: 26upx 30upx;
   border-bottom: 1px solid $borderColor;
@@ -348,6 +534,11 @@ export default {
   color: $fontColor3;
 }
 
+.real-stock-text {
+  text-align: right;
+  color: $fontColor2;
+}
+
 .refund-tip {
   padding: 16upx 30upx 0;
   font-size: 24upx;
@@ -355,12 +546,6 @@ export default {
   line-height: 1.5;
 }
 
-.tip-text {
-  padding: 20upx 30upx;
-  font-size: 24upx;
-  color: $fontColor3;
-}
-
 .bottom-bar {
   position: fixed;
   left: 0;

+ 20 - 0
mallApp/src/api/group-buy/index.js

@@ -0,0 +1,20 @@
+/**
+ * 拼团(团购)相关 API
+ * 供团购详情页:落地信息、团详情、开团、参团、我的拼团
+ */
+import https from '@/plugins/luch-request_0.0.7/request'
+
+/** 团购商品落地页:活动商品 + 当前开放团 */
+export const getGoodsLanding = data => https.get('/group-buy/goods-landing', data)
+
+/** 指定拼团详情(分享进入用 id=groupBuyId) */
+export const getGroupDetail = data => https.get('/group-buy/detail', data)
+
+/** 我要开团 */
+export const createGroup = data => https.post('/group-buy/create', data)
+
+/** 我要参团 */
+export const joinGroup = data => https.post('/group-buy/join', data)
+
+/** 我的拼团列表 */
+export const getMyList = data => https.get('/group-buy/my-list', data)

+ 121 - 0
mallApp/src/components/CountDown.vue

@@ -0,0 +1,121 @@
+<!--
+  通用倒计时组件
+  用途:团购详情页活动倒计时、拼团剩余时间等
+  入参 endTime 为秒级 unix;结束后触发 ended 事件
+-->
+<template>
+  <view v-if="visible" class="count-down" :class="theme">
+    <text v-if="label" class="cd-label">{{ label }}</text>
+    <view class="cd-blocks">
+      <text class="cd-unit">{{ display.h }}</text>
+      <text class="cd-colon">:</text>
+      <text class="cd-unit">{{ display.m }}</text>
+      <text class="cd-colon">:</text>
+      <text class="cd-unit">{{ display.s }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'CountDown',
+  props: {
+    /** 结束时间(秒级 unix 时间戳) */
+    endTime: { type: [Number, String], default: 0 },
+    /** 文案前缀,如「距结束」 */
+    label: { type: String, default: '' },
+    /** 主题:default | pink */
+    theme: { type: String, default: 'default' }
+  },
+  data() {
+    return {
+      visible: false,
+      display: { h: '00', m: '00', s: '00' },
+      _timer: null
+    }
+  },
+  watch: {
+    endTime: {
+      immediate: true,
+      handler() {
+        this.restart()
+      }
+    }
+  },
+  beforeDestroy() {
+    this.clearTimer()
+  },
+  methods: {
+    clearTimer() {
+      if (this._timer) {
+        clearInterval(this._timer)
+        this._timer = null
+      }
+    },
+    restart() {
+      this.clearTimer()
+      this.tick()
+      this._timer = setInterval(this.tick, 1000)
+    },
+    tick() {
+      const end = Number(this.endTime) || 0
+      const now = Math.floor(Date.now() / 1000)
+      if (end <= 0 || now >= end) {
+        this.visible = false
+        this.display = { h: '00', m: '00', s: '00' }
+        this.clearTimer()
+        this.$emit('ended')
+        return
+      }
+      this.visible = true
+      let diff = end - now
+      const h = Math.floor(diff / 3600)
+      diff -= h * 3600
+      const m = Math.floor(diff / 60)
+      const s = diff % 60
+      const pad = (n) => String(n).padStart(2, '0')
+      this.display = { h: pad(h), m: pad(m), s: pad(s) }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.count-down {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.cd-label {
+  font-size: 22upx;
+  color: #999;
+  margin-right: 8upx;
+}
+.cd-blocks {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.cd-unit {
+  min-width: 40upx;
+  height: 36upx;
+  line-height: 36upx;
+  text-align: center;
+  font-size: 22upx;
+  color: #fff;
+  background: #ff4d6d;
+  border-radius: 6upx;
+  padding: 0 6upx;
+}
+.cd-colon {
+  margin: 0 4upx;
+  color: #ff4d6d;
+  font-size: 22upx;
+  font-weight: 600;
+}
+.pink {
+  .cd-unit {
+    background: #ff6b8a;
+  }
+}
+</style>

+ 100 - 0
mallApp/src/components/GroupAvatarStack.vue

@@ -0,0 +1,100 @@
+<!--
+  拼团成员头像叠加
+  用途:团购详情「当前拼团中」卡片展示已参团头像 + 空位加号
+  list: [{avatar,name}];groupSize 为成团总人数;用负 margin 叠加,禁用 gap
+-->
+<template>
+  <view class="avatar-stack">
+    <view
+      v-for="(slot, index) in slots"
+      :key="index"
+      class="avatar-item"
+      :class="{ first: index === 0, empty: slot.empty }"
+    >
+      <image
+        v-if="!slot.empty"
+        class="avatar-img"
+        :src="slot.avatar | default_avatar"
+        mode="aspectFill"
+      />
+      <view v-else class="avatar-empty">
+        <text class="plus">+</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'GroupAvatarStack',
+  props: {
+    /** 已参团成员(已支付) */
+    list: { type: Array, default: () => [] },
+    /** 成团所需人数 */
+    groupSize: { type: Number, default: 3 },
+    /** 最多展示几个头像槽位 */
+    max: { type: Number, default: 5 }
+  },
+  computed: {
+    slots() {
+      const size = Math.min(Number(this.groupSize) || 3, this.max)
+      const members = Array.isArray(this.list) ? this.list : []
+      const result = []
+      for (let i = 0; i < size; i++) {
+        const m = members[i]
+        if (m) {
+          result.push({
+            empty: false,
+            avatar: m.avatar || m.avatarUrl || ''
+          })
+        } else {
+          result.push({ empty: true, avatar: '' })
+        }
+      }
+      return result
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.avatar-stack {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.avatar-item {
+  width: 64upx;
+  height: 64upx;
+  border-radius: 50%;
+  overflow: hidden;
+  border: 4upx solid #fff;
+  background: #fff;
+  margin-left: -16upx;
+  box-sizing: border-box;
+  &.first {
+    margin-left: 0;
+  }
+}
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+.avatar-empty {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  border: 2upx dashed #ffb3c1;
+  background: #fff5f7;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+.plus {
+  color: #ff6b8a;
+  font-size: 32upx;
+  line-height: 1;
+}
+</style>

+ 8 - 1
mallApp/src/components/home/activitySection.vue

@@ -2,7 +2,7 @@
   店铺首页-秒杀专区/团购专区
   按 layoutCols(1/2/3) 渲染活动商品,含倒计时;秒杀按钮为「立即抢购」,团购按钮为「去开团」;
   仅在活动有效期内(startTime~endTime)展示,未开始或结束后整块隐藏;
-  点击商品/按钮跳转商品详情页(秒杀/团购下单流程暂沿用商品详情页,未来可扩展专属下单页
+  秒杀点击跳商品详情;团购点击跳团购详情页(开团/参团入口
 -->
 <template>
   <view v-if="isActive && data && data.enabled == 1 && data.goods && data.goods.length" class="home-activity">
@@ -132,6 +132,13 @@ export default {
       if (!g) return
       const id = g.id || g.goodsId
       if (!id) return
+      // 团购:进入专属团购详情页(开团/参团/分享),不再走普通商品详情
+      if (this.type === 'groupBuy') {
+        this.pageTo({
+          url: `/pages/groupBuy/detail?goodsId=${id}&account=${this.account}&hdId=${this.hdId}`
+        })
+        return
+      }
       let url = `/pages/goods/detail?id=${id}&account=${this.account}&hdId=${this.hdId}`
       // 秒杀商品:把活动价/限购/结束时间带到详情页,详情页据此展示秒杀价并在加入购物车时打上活动标记;
       // 实际下单价格仍由后端独立校验 Redis 秒杀配置核价,这里带的数据仅用于前端展示与标记

+ 20 - 1
mallApp/src/components/order/order-item.vue

@@ -12,6 +12,7 @@
         <text class="iconfont iconxiangyou shop-arrow"></text>
       </view>
       <view class="status-wrap">
+        <text v-if="info.groupBuyId > 0 && groupBuyStatusText" class="tag tag-groupbuy">{{ groupBuyStatusText }}</text>
         <text v-if="info.refund == 2" class="tag tag-refund">有退款</text>
         <text v-if="info.debt == 1" class="tag tag-debt">赊账</text>
         <text v-if="info.book == 1" class="tag tag-book">预订单</text>
@@ -95,6 +96,18 @@ export default {
       };
       return statusMap[String(this.info.status)] || '';
     },
+    /** 拼团状态文案(有 groupBuyId 时展示) */
+    groupBuyStatusText() {
+      const map = {
+        '1': '拼团中',
+        '2': '拼团成功',
+        '3': '拼团失败已退款',
+        '4': '拼团已取消'
+      };
+      const status = this.info.groupBuyStatus;
+      if (status === undefined || status === null || status === '') return '';
+      return map[String(status)] || '';
+    },
     /** 状态颜色样式类 */
     statusClass() {
       const map = {
@@ -227,11 +240,17 @@ export default {
 }
 
 .tag-refund,
-.tag-debt {
+.tag-debt,
+.tag-groupbuy {
   color: $redColor;
   border: 1upx solid $redColor;
 }
 
+.tag-groupbuy {
+  color: #ff6b8a;
+  border-color: #ff6b8a;
+}
+
 .tag-book {
   color: #fff;
   background: #657cac;

+ 7 - 0
mallApp/src/filters/constant.js

@@ -80,6 +80,13 @@ const _CONST_MAP = {
 			value: 6
 		}
 	],
+	// 拼团状态(与后端 GroupBuyClass 常量对齐)
+	GROUP_BUY_STATUS: [
+		{ label: '拼团中', value: 1 },
+		{ label: '拼团成功', value: 2 },
+		{ label: '拼团失败已退款', value: 3 },
+		{ label: '已取消', value: 4 }
+	],
 	// 配送状态
 	SEND_STATUS: [
 		{

+ 3 - 2
mallApp/src/mixins/cgProduct.js

@@ -258,12 +258,13 @@ export default {
 		isBouquetItem(item) {
 			return this.getItemProperty(item) === 0;
 		},
-		/** 购物车行唯一键:花束用 id+property+specGoodsId,花材用 id(+classId) */
+		/** 购物车行唯一键:花束用 id+property+specGoodsId+activityType,花材用 id(+classId) */
 		getSelectRowKey(item) {
 			if (!item || item.id === undefined || item.id === null) return '';
 			const property = this.getItemProperty(item);
 			if (property === 0) {
-				return `0_${item.id}_${item.specGoodsId || 0}`;
+				// 与 _selectRowMatches 对齐:同规格秒杀与普通购买必须分行,避免不同计价规则被合并
+				return `0_${item.id}_${item.specGoodsId || 0}_${item.activityType || ''}`;
 			}
 			if (this._shouldMergeSelectByProductId()) {
 				return `1_${item.id}`;

+ 6 - 0
mallApp/src/pages.json

@@ -81,6 +81,12 @@
                 { "path": "section-list", "style": { "navigationBarTitleText": "商品列表", "enablePullDownRefresh": true } }
             ]
         },
+        {
+            "root": "pages/groupBuy",
+            "pages": [
+                { "path": "detail", "style": { "navigationBarTitleText": "团购详情" } }
+            ]
+        },
         {
             "root": "pages/login",
             "pages": [

+ 2 - 2
mallApp/src/pages/callback/pay.vue

@@ -121,8 +121,8 @@ export default {
       this.pageStatus = this.option.pageStatus;
       this.initClass(this.pageStatus);
       getShopUser(true)
-      let id = this.option.id?this.option.id:0
-      this.getOrderInfo(id)
+      // let id = this.option.id?this.option.id:0
+      // this.getOrderInfo(id)
     },
     getOrderInfo(id){
       getDetail({id:id}).then(res=>{

+ 7 - 0
mallApp/src/pages/goods/section-list.vue

@@ -136,6 +136,13 @@ export default {
       if (!item) return
       const id = item.id || item.goodsId
       if (!id) return
+      // 团购更多列表:进入团购详情页
+      if (this.isGroupBuy) {
+        this.pageTo({
+          url: `/pages/groupBuy/detail?goodsId=${id}&account=${this.account}&hdId=${this.hdId}`
+        })
+        return
+      }
       let url = `/pages/goods/detail?id=${id}&account=${this.account}&hdId=${this.hdId}`
       if (this.isSeckill) {
         url += `&activityType=seckill&activityPrice=${item.price}&activityLimit=${item.limit || 0}&activityStock=${item.stock || 0}&activityEndTime=${this.activityEndTime}`

+ 720 - 0
mallApp/src/pages/groupBuy/detail.vue

@@ -0,0 +1,720 @@
+<!--
+  团购详情 / 开团入口页
+  从首页团购专区点击进入(goodsId),或从分享卡片进入(id=groupBuyId)
+  展示商品信息、当前拼团卡片、开团玩法、底部开团/参团/分享操作
+-->
+<template>
+  <view class="group-buy-detail" v-if="loaded">
+    <!-- 商品图 -->
+    <view class="hero">
+      <swiper class="hero-swiper" :indicator-dots="false" @change="onSwiperChange">
+        <swiper-item v-for="(img, idx) in swiperList" :key="idx">
+          <image class="hero-img" :src="img" mode="aspectFill" />
+        </swiper-item>
+      </swiper>
+      <view class="hero-badge">团购商品</view>
+      <view class="hero-index">{{ swiperIndex + 1 }}/{{ swiperList.length }}</view>
+    </view>
+
+    <!-- 价格信息 -->
+    <view class="info-card">
+      <view class="info-top">
+        <view class="price-col">
+          <view class="price-row">
+            <text class="price">¥{{ formatPrice(goods.price) }}</text>
+            <text class="origin">¥{{ formatPrice(goods.originPrice) }}</text>
+            <text class="group-tag">{{ goods.groupSize || 3 }}人成团</text>
+          </view>
+        </view>
+        <count-down
+          v-if="goods.endTime"
+          :end-time="goods.endTime"
+          label="距结束"
+          theme="pink"
+          @ended="onActivityEnded"
+        />
+      </view>
+      <text class="goods-name">{{ goods.name }}</text>
+      <text class="goods-desc">{{ goods.subtitle || goods.desc || '精选鲜花,拼团更优惠' }}</text>
+    </view>
+
+    <!-- 当前拼团中卡片 -->
+    <view v-if="openGroup && openGroup.id" class="open-group-card">
+      <view class="og-head">
+        <text class="og-title">当前拼团中,还差{{ openGroup.remainNum }}人成团</text>
+        <text class="og-count">已拼{{ openGroup.currentNum }}人</text>
+      </view>
+      <view class="og-body">
+        <group-avatar-stack
+          :list="openGroup.members || []"
+          :group-size="openGroup.needNum || goods.groupSize || 3"
+        />
+        <button class="og-btn" @click="joinOpenGroup">去参团</button>
+      </view>
+      <text class="og-tip">剩余 {{ remainText }} 自动退款</text>
+    </view>
+
+    <!-- 开团玩法 -->
+    <view class="howto-card">
+      <text class="howto-title">开团玩法</text>
+      <view class="howto-steps">
+        <view class="step" v-for="(s, i) in steps" :key="i">
+          <view class="step-icon">{{ i + 1 }}</view>
+          <text class="step-name">{{ s.name }}</text>
+          <text class="step-desc">{{ s.desc }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 商品详情图 -->
+    <view class="detail-section">
+      <text class="detail-title">商品详情</text>
+      <image
+        v-for="(img, idx) in detailImages"
+        :key="idx"
+        class="detail-img"
+        :src="img"
+        mode="widthFix"
+      />
+      <view v-if="!detailImages.length" class="detail-empty">
+        <text>{{ goods.desc || '暂无更多详情' }}</text>
+      </view>
+    </view>
+
+    <!-- 底部操作栏 -->
+    <view class="foot-bar">
+      <button class="foot-side" @click="buyAlone">
+        <i class="iconfont icongouwuche foot-side-icon"></i>
+        <text class="foot-side-text">单独购买</text>
+      </button>
+      <button class="foot-main" @click="startGroup">我要开团</button>
+      <button class="foot-side" open-type="share" @click="prepareShare">
+        <i class="iconfont iconfenxiang foot-side-icon"></i>
+        <text class="foot-side-text">邀请好友</text>
+      </button>
+    </view>
+
+    <!-- 分享菜单(非小程序或需主动选择时) -->
+    <view v-if="showShareMenu" class="share-mask" @click="showShareMenu = false">
+      <view class="share-panel" @click.stop>
+        <button class="share-item" open-type="share" @click="onDirectShare">直接分享</button>
+        <button class="share-item muted" @click="onPosterTip">生成海报</button>
+        <button class="share-cancel" @click="showShareMenu = false">取消</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * 团购详情页
+ * 入参:goodsId(首页进入)或 id(分享进入的 groupBuyId)
+ * 开团/参团成功后跳转待付款页
+ */
+import CountDown from '@/components/CountDown.vue'
+import GroupAvatarStack from '@/components/GroupAvatarStack.vue'
+import share from '@/mixins/share'
+import {
+  getGoodsLanding,
+  getGroupDetail,
+  createGroup,
+  joinGroup
+} from '@/api/group-buy'
+
+export default {
+  name: 'GroupBuyDetail',
+  components: { CountDown, GroupAvatarStack },
+  mixins: [share],
+  data() {
+    return {
+      loaded: false,
+      goodsId: 0,
+      groupBuyId: 0,
+      goods: {},
+      openGroup: null,
+      detailImages: [],
+      swiperIndex: 0,
+      remainText: '00:00:00',
+      _remainTimer: null,
+      showShareMenu: false,
+      submitting: false,
+      steps: [
+        { name: '选拼商品', desc: '挑选心仪花束' },
+        { name: '邀请好友', desc: '分享邀请好友参团' },
+        { name: '拼团成功', desc: '人数满后发货' }
+      ]
+    }
+  },
+  computed: {
+    account() {
+      return (this.option && this.option.account) || uni.getStorageSync('account') || ''
+    },
+    hdId() {
+      return (this.option && this.option.hdId) || uni.getStorageSync('hdId') || ''
+    },
+    swiperList() {
+      const cover = this.goods.coverUrl || this.goods.cover
+      const list = []
+      if (cover) list.push(cover)
+      ;(this.detailImages || []).forEach((img) => {
+        if (img && list.indexOf(img) === -1) list.push(img)
+      })
+      return list.length ? list : ['/static/images/user/attr-default.png']
+    },
+    shareGroupId() {
+      if (this.groupBuyId) return this.groupBuyId
+      if (this.openGroup && this.openGroup.id) return this.openGroup.id
+      return 0
+    }
+  },
+  onLoad(option) {
+    this.option = option || {}
+    this.goodsId = Number(option.goodsId || 0)
+    this.groupBuyId = Number(option.id || option.groupBuyId || 0)
+    this.init()
+  },
+  onUnload() {
+    this.clearRemainTimer()
+  },
+  onShareAppMessage() {
+    return this.buildSharePayload()
+  },
+  methods: {
+    formatPrice(val) {
+      const n = parseFloat(val)
+      if (isNaN(n)) return '0'
+      return n % 1 === 0 ? String(n) : n.toFixed(2)
+    },
+    init() {
+      uni.showLoading({ title: '加载中', mask: true })
+      const tasks = []
+      if (this.groupBuyId) {
+        tasks.push(getGroupDetail({ id: this.groupBuyId }))
+      }
+      if (this.goodsId) {
+        tasks.push(getGoodsLanding({ goodsId: this.goodsId }))
+      }
+      if (!tasks.length) {
+        uni.hideLoading()
+        this.$msg('参数错误')
+        return
+      }
+      Promise.all(tasks)
+        .then((results) => {
+          uni.hideLoading()
+          if (this.groupBuyId) {
+            const res = results[0]
+            if (res.code === 1 && res.data) {
+              this.applyGroupDetail(res.data)
+              // 分享进入时若无 goodsId,用团上的商品再拉落地信息补全详情图
+              if (!this.goodsId && res.data.goodsId) {
+                this.goodsId = Number(res.data.goodsId)
+                return getGoodsLanding({ goodsId: this.goodsId }).then((land) => {
+                  if (land.code === 1 && land.data) this.applyLanding(land.data, true)
+                })
+              }
+            }
+            if (results[1] && results[1].code === 1) {
+              this.applyLanding(results[1].data, true)
+            }
+          } else {
+            const res = results[0]
+            if (res.code === 1 && res.data) this.applyLanding(res.data, false)
+          }
+        })
+        .catch(() => {
+          uni.hideLoading()
+        })
+        .finally(() => {
+          this.loaded = true
+          this.setupShare()
+        })
+    },
+    /** 应用落地页数据;keepOpenGroup=true 时保留分享带来的团信息 */
+    applyLanding(data, keepOpenGroup) {
+      if (!data) return
+      this.goods = {
+        activityGoodsId: data.activityGoodsId,
+        goodsId: data.goodsId,
+        name: data.name,
+        cover: data.cover,
+        coverUrl: data.coverUrl || data.cover,
+        price: data.price,
+        originPrice: data.originPrice,
+        groupSize: data.groupSize,
+        endTime: data.endTime,
+        startTime: data.startTime,
+        subtitle: data.subtitle,
+        desc: data.desc,
+        title: data.title
+      }
+      this.detailImages = data.detailImages || []
+      if (!keepOpenGroup || !this.openGroup) {
+        this.openGroup = data.openGroup || null
+      }
+      this.startRemainTimer()
+    },
+    applyGroupDetail(data) {
+      if (!data) return
+      this.groupBuyId = data.id
+      this.openGroup = data
+      if (data.goods) {
+        this.goods = Object.assign({}, this.goods, {
+          goodsId: data.goods.goodsId || data.goodsId,
+          name: data.goods.name || this.goods.name,
+          cover: data.goods.cover || this.goods.cover,
+          coverUrl: data.goods.cover || this.goods.coverUrl,
+          price: data.goods.price || data.price,
+          originPrice: data.goods.originPrice || this.goods.originPrice,
+          groupSize: data.goods.groupSize || data.needNum
+        })
+      }
+      if (data.activityGoods) {
+        this.applyLanding(
+          Object.assign({}, data.activityGoods, {
+            openGroup: data,
+            detailImages: this.detailImages
+          }),
+          true
+        )
+      }
+      this.startRemainTimer()
+    },
+    startRemainTimer() {
+      this.clearRemainTimer()
+      this.tickRemain()
+      this._remainTimer = setInterval(this.tickRemain, 1000)
+    },
+    clearRemainTimer() {
+      if (this._remainTimer) {
+        clearInterval(this._remainTimer)
+        this._remainTimer = null
+      }
+    },
+    tickRemain() {
+      const end = Number(this.openGroup && this.openGroup.deadline) || 0
+      const now = Math.floor(Date.now() / 1000)
+      if (!end || now >= end) {
+        this.remainText = '00:00:00'
+        return
+      }
+      let diff = end - now
+      const h = Math.floor(diff / 3600)
+      diff -= h * 3600
+      const m = Math.floor(diff / 60)
+      const s = diff % 60
+      const pad = (n) => String(n).padStart(2, '0')
+      this.remainText = `${pad(h)}:${pad(m)}:${pad(s)}`
+    },
+    onSwiperChange(e) {
+      this.swiperIndex = (e.detail && e.detail.current) || 0
+    },
+    onActivityEnded() {
+      this.$msg('活动已结束')
+    },
+    buyAlone() {
+      const id = this.goods.goodsId || this.goodsId
+      if (!id) return
+      this.pageTo({
+        url: `/pages/goods/detail?id=${id}&account=${this.account}&hdId=${this.hdId}`
+      })
+    },
+    /** 我要开团:默认自取下单,成功后去待付款 */
+    startGroup() {
+      if (this.submitting) return
+      const activityGoodsId = this.goods.activityGoodsId
+      if (!activityGoodsId) {
+        this.$msg('团购商品无效')
+        return
+      }
+      this.submitting = true
+      uni.showLoading({ title: '开团中', mask: true })
+      createGroup({
+        activityGoodsId,
+        goodsNum: 1,
+        sendType: 1
+      })
+        .then((res) => {
+          uni.hideLoading()
+          this.submitting = false
+          if (res.code === 1 && res.data) {
+            this.groupBuyId = res.data.groupBuyId
+            this.setupShare()
+            this.goPay(res.data)
+          }
+        })
+        .catch(() => {
+          uni.hideLoading()
+          this.submitting = false
+        })
+    },
+    joinOpenGroup() {
+      if (!this.openGroup || !this.openGroup.id) return
+      this.doJoin(this.openGroup.id)
+    },
+    doJoin(groupBuyId) {
+      if (this.submitting) return
+      this.submitting = true
+      uni.showLoading({ title: '参团中', mask: true })
+      joinGroup({
+        groupBuyId,
+        goodsNum: 1,
+        sendType: 1
+      })
+        .then((res) => {
+          uni.hideLoading()
+          this.submitting = false
+          if (res.code === 1 && res.data) {
+            this.groupBuyId = res.data.groupBuyId
+            this.setupShare()
+            this.goPay(res.data)
+          }
+        })
+        .catch(() => {
+          uni.hideLoading()
+          this.submitting = false
+        })
+    },
+    goPay(data) {
+      const orderSn = data.orderSn || ''
+      const id = data.id || 0
+      const totalPrice = data.totalPrice || 0
+      this.$util.pageTo({
+        url:
+          `/pages/callback/pay?pageStatus=1&orderSn=${orderSn}&hdId=${this.hdId}&account=${this.account}&id=${id}&couponId=0&totalPrice=${totalPrice}`,
+        type: 2
+      })
+    },
+    prepareShare() {
+      this.setupShare()
+      // #ifndef MP-WEIXIN
+      this.showShareMenu = true
+      // #endif
+    },
+    onDirectShare() {
+      this.showShareMenu = false
+      this.setupShare()
+    },
+    onPosterTip() {
+      this.$msg('海报生成功能即将上线')
+      this.showShareMenu = false
+    },
+    buildSharePayload() {
+      const remain = this.openGroup
+        ? Number(this.openGroup.remainNum)
+        : Math.max(0, (this.goods.groupSize || 3) - 1)
+      const title = remain > 0 ? `还差${remain}人成团` : (this.goods.name || '一起来拼团')
+      const imageUrl = this.goods.coverUrl || this.goods.cover || ''
+      let path = ''
+      if (this.shareGroupId) {
+        path = `pages/groupBuy/detail?id=${this.shareGroupId}&account=${this.account}`
+      } else {
+        path = `pages/groupBuy/detail?goodsId=${this.goods.goodsId || this.goodsId}&account=${this.account}`
+      }
+      if (this.hdId) path += `&hdId=${this.hdId}`
+      return { title, path, imageUrl }
+    },
+    setupShare() {
+      const payload = this.buildSharePayload()
+      this.shareData = payload
+      if (typeof this.jweixinFn === 'function') {
+        this.jweixinFn({
+          title: payload.title,
+          desc: this.goods.name || '团购优惠',
+          imgUrl: payload.imageUrl,
+          pagePath: payload.path
+        })
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.group-buy-detail {
+  min-height: 100vh;
+  background: #f7f7f7;
+  padding-bottom: 200upx;
+}
+.hero {
+  position: relative;
+  width: 100%;
+  height: 750upx;
+  background: #eee;
+}
+.hero-swiper,
+.hero-img {
+  width: 100%;
+  height: 750upx;
+}
+.hero-badge {
+  position: absolute;
+  left: 0;
+  top: 24upx;
+  background: #ff3b5c;
+  color: #fff;
+  font-size: 22upx;
+  padding: 8upx 16upx;
+  border-radius: 0 20upx 20upx 0;
+}
+.hero-index {
+  position: absolute;
+  right: 24upx;
+  bottom: 24upx;
+  background: rgba(0, 0, 0, 0.45);
+  color: #fff;
+  font-size: 22upx;
+  padding: 4upx 16upx;
+  border-radius: 20upx;
+}
+.info-card {
+  background: #fff;
+  padding: 28upx 24upx;
+}
+.info-top {
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+.price-row {
+  display: flex;
+  flex-direction: row;
+  align-items: baseline;
+}
+.price {
+  color: #ff3b5c;
+  font-size: 48upx;
+  font-weight: 700;
+}
+.origin {
+  margin-left: 12upx;
+  color: #bbb;
+  font-size: 24upx;
+  text-decoration: line-through;
+}
+.group-tag {
+  margin-left: 12upx;
+  background: #ffe6ec;
+  color: #ff4d6d;
+  font-size: 20upx;
+  padding: 4upx 12upx;
+  border-radius: 8upx;
+}
+.goods-name {
+  display: block;
+  margin-top: 16upx;
+  font-size: 34upx;
+  font-weight: 600;
+  color: #222;
+}
+.goods-desc {
+  display: block;
+  margin-top: 10upx;
+  font-size: 24upx;
+  color: #999;
+  line-height: 1.5;
+}
+.open-group-card {
+  margin: 20upx 24upx 0;
+  background: #fff0f4;
+  border-radius: 16upx;
+  padding: 24upx;
+}
+.og-head {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+}
+.og-title {
+  font-size: 28upx;
+  color: #333;
+  font-weight: 600;
+}
+.og-count {
+  font-size: 22upx;
+  color: #999;
+}
+.og-body {
+  margin-top: 20upx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+}
+.og-btn {
+  background: #ff6b8a;
+  color: #fff;
+  font-size: 26upx;
+  border-radius: 40upx;
+  padding: 0 36upx;
+  height: 64upx;
+  line-height: 64upx;
+  margin: 0;
+}
+.og-tip {
+  display: block;
+  margin-top: 16upx;
+  font-size: 22upx;
+  color: #999;
+}
+.howto-card {
+  margin: 20upx 24upx 0;
+  background: #fff;
+  border-radius: 16upx;
+  padding: 28upx 20upx;
+}
+.howto-title {
+  font-size: 30upx;
+  font-weight: 600;
+  color: #222;
+}
+.howto-steps {
+  margin-top: 24upx;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+.step {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.step-icon {
+  width: 56upx;
+  height: 56upx;
+  border-radius: 50%;
+  background: #e8f8e0;
+  color: #77c34f;
+  font-size: 26upx;
+  font-weight: 700;
+  text-align: center;
+  line-height: 56upx;
+}
+.step-name {
+  margin-top: 12upx;
+  font-size: 26upx;
+  color: #333;
+}
+.step-desc {
+  margin-top: 6upx;
+  font-size: 20upx;
+  color: #999;
+  text-align: center;
+}
+.detail-section {
+  margin-top: 20upx;
+  background: #fff;
+  padding: 28upx 0 40upx;
+}
+.detail-title {
+  display: block;
+  padding: 0 24upx 20upx;
+  font-size: 30upx;
+  font-weight: 600;
+}
+.detail-img {
+  width: 100%;
+  display: block;
+}
+.detail-empty {
+  padding: 40upx 24upx;
+  color: #999;
+  font-size: 26upx;
+}
+.foot-bar {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 99;
+  background: #fff;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 16upx 20upx;
+  padding-bottom: calc(16upx + constant(safe-area-inset-bottom));
+  padding-bottom: calc(16upx + env(safe-area-inset-bottom));
+  box-shadow: 0 -2upx 10upx rgba(0, 0, 0, 0.05);
+}
+.foot-side {
+  width: 140upx;
+  background: #fff;
+  border: 2upx solid #eee;
+  border-radius: 44upx;
+  height: 80upx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin: 0;
+  padding: 0;
+  line-height: 1.2;
+  &::after {
+    border: none;
+  }
+}
+.foot-side-icon {
+  font-size: 32upx;
+  line-height: 1;
+  color: #666;
+}
+.foot-side-text {
+  font-size: 20upx;
+  color: #666;
+  margin-top: 4upx;
+}
+.foot-main {
+  flex: 1;
+  margin: 0 16upx;
+  height: 80upx;
+  line-height: 80upx;
+  background: #ff6b8a;
+  color: #fff;
+  font-size: 30upx;
+  font-weight: 600;
+  border-radius: 44upx;
+  &::after {
+    border: none;
+  }
+}
+.share-mask {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.45);
+  z-index: 200;
+  display: flex;
+  align-items: flex-end;
+}
+.share-panel {
+  width: 100%;
+  background: #fff;
+  border-radius: 24upx 24upx 0 0;
+  padding: 20upx 0;
+  padding-bottom: calc(20upx + env(safe-area-inset-bottom));
+}
+.share-item,
+.share-cancel {
+  width: 100%;
+  background: #fff;
+  font-size: 30upx;
+  color: #333;
+  height: 96upx;
+  line-height: 96upx;
+  &::after {
+    border: none;
+  }
+}
+.share-item.muted {
+  color: #999;
+}
+.share-cancel {
+  border-top: 12upx solid #f5f5f5;
+  color: #666;
+}
+</style>

+ 8 - 0
mallApp/src/pages/order/detail.vue

@@ -7,6 +7,9 @@
       <view class="page-top-det">
         <view>
           <view class="page-status">{{ data.status | constantfilter('ORDER_STATUS') }}</view>
+          <view v-if="data.groupBuyId > 0 && data.groupBuyStatus" class="page-groupbuy-status">
+            {{ data.groupBuyStatus | constantfilter('GROUP_BUY_STATUS') }}
+          </view>
         </view>
         <view class="page-status-img">
           <img :src="statusImg" alt />
@@ -245,6 +248,11 @@ export default {
       font-weight: bold;
       margin-bottom: 6upx; // 进一步减小底部间距
     }
+    .page-groupbuy-status {
+      font-size: 24upx;
+      color: #ffe6ec;
+      margin-top: 4upx;
+    }
     .page-prompt {
       color: #ffdede;
     }