2026-08-04-product-sku-selector.md 15 KB

商品 SKU 选择器 实现计划

面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(- [ ])语法来跟踪进度。

目标: 在商品详情页添加 SKU 规格选择功能,让用户在加购/下单前选择规格,会员价联动展示

架构: 使用现有 /api/product/spec/map 接口获取规格分组数据,在详情页 info-section 和 intro-section 之间插入规格选择器,选择后同步更新价格/库存,加购/下单时传递 skuId 和 specDesc

技术栈: uni-app Vue 2 Options API,微信小程序

设计文档: docs/superpowers/specs/2026-08-04-product-sku-selector-design.md


任务 1:product-detail.vue — 数据层改造

文件:

  • 修改:cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

  • [ ] 步骤 1:添加 imports 和 data 属性

在 import 中添加 productSpecMap 和 getMyMembership:

import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership } from '@/utils/api.js'

在 data() 中添加:

data() {
  return {
    product: {},
    loading: false,
    showPoster: false,
    posterQrCode: '',
    posterDimension: '',
    posterInviteText: '',
    // SKU 选择器状态
    specGroups: [],       // 规格分组数据
    selectedSpecs: {},    // { groupId: optionId }
    selectedSku: null,    // 匹配到的完整 SKU
    isMember: false       // 会员状态
  }
}
  • 步骤 2:添加计算属性

在 computed 中添加:

computed: {
  // ... 现有计算属性 ...

  hasSku: function() {
    return this.specGroups && this.specGroups.length > 0
  },

  allSpecsSelected: function() {
    if (!this.hasSku) return true
    for (var i = 0; i < this.specGroups.length; i++) {
      if (!this.selectedSpecs[this.specGroups[i].groupId]) return false
    }
    return true
  },

  displayPrice: function() {
    // 有 SKU 且已选 → 显示 SKU 价格
    if (this.selectedSku) return this.selectedSku.price
    // 有 SKU 但未选 → 显示产品默认价格
    if (this.hasSku) return this.product.price || 0
    // 无 SKU → 显示产品价格
    return this.product.price || 0
  },

  displayMemberPrice: function() {
    if (this.selectedSku) {
      // 如果 SKU 有 memberPrice,用;否则用产品的 memberPrice
      return this.product.memberPrice || 0
    }
    return this.product.memberPrice || 0
  },

  displayStock: function() {
    if (this.selectedSku) return this.selectedSku.stock || 0
    if (this.hasSku) return 0
    return this.product.stock || 0
  },

  buyButtonDisabled: function() {
    return this.hasSku && !this.allSpecsSelected
  },

  buyButtonText: function() {
    if (this.hasSku && !this.allSpecsSelected) return '请选择完整规格'
    return '立即购买'
  }
}
  • 步骤 3:修改 loadDetail 方法

修改 loadDetail 在加载商品详情后获取规格数据:

loadDetail: function(id) {
  this.loading = true
  var that = this
  productDetail({ id: parseInt(id) }).then(function(res) {
    that.loading = false
    if (res.code === 200 && res.data) {
      that.product = res.data
      that.setShareInfo(
        that.product.name,
        '/pages/discover-detail/product-detail/product-detail?id=' + that.product.id
      )
      // 加载 SKU 规格数据
      that.loadSkuData(id)
    }
  }).catch(function() {
    that.loading = false
  })
}
  • 步骤 4:添加 loadSkuData 和 checkMemberStatus 方法

在 methods 中添加:

loadSkuData: function(productId) {
  var that = this
  productSpecMap({ productIds: [parseInt(productId)] }).then(function(res) {
    if (res.code === 200 && res.data) {
      var map = res.data
      var groups = map[productId]
      if (groups && groups.length > 0) {
        that.specGroups = groups
      }
    }
  }).catch(function() {
    // 规格加载失败不阻塞页面
  })
},

checkMemberStatus: function() {
  var that = this
  getMyMembership().then(function(res) {
    if (res.data && res.data.memberLevel) {
      that.isMember = res.data.memberLevel !== 'FREE'
    }
  }).catch(function() {})
}

在 onLoad 末尾调用 checkMemberStatus:

onLoad: function(options) {
  // ... 现有代码 ...
  if (pid) {
    this.loadDetail(pid)
    this.checkMemberStatus()
  }
}
  • 步骤 5:添加规格选择方法

在 methods 中添加:

selectSpec: function(groupId, option) {
  // 切换选中状态
  if (this.selectedSpecs[groupId] === option.id) {
    // 点击已选中的 → 取消选中
    delete this.selectedSpecs[groupId]
    this.selectedSku = null
  } else {
    this.selectedSpecs[groupId] = option.id
    this.matchSku()
  }
  // 触发视图更新
  this.$forceUpdate()
},

matchSku: function() {
  // 检查是否所有组都已选
  for (var i = 0; i < this.specGroups.length; i++) {
    if (!this.selectedSpecs[this.specGroups[i].groupId]) {
      this.selectedSku = null
      return
    }
  }
  // 取所有选中选项的 skuId 的交集
  var commonSkuIds = null
  for (var j = 0; j < this.specGroups.length; j++) {
    var group = this.specGroups[j]
    var selectedOptionId = this.selectedSpecs[group.groupId]
    var skuIds = []
    for (var k = 0; k < group.options.length; k++) {
      if (group.options[k].id === selectedOptionId) {
        skuIds.push(group.options[k].skuId)
      }
    }
    if (commonSkuIds === null) {
      commonSkuIds = skuIds
    } else {
      // 取交集
      var intersection = []
      for (var m = 0; m < commonSkuIds.length; m++) {
        for (var n = 0; n < skuIds.length; n++) {
          if (commonSkuIds[m] === skuIds[n]) {
            intersection.push(commonSkuIds[m])
            break
          }
        }
      }
      commonSkuIds = intersection
    }
  }
  if (commonSkuIds && commonSkuIds.length === 1) {
    // 找到匹配的 SKU,从 specMap 数据中取价格和库存
    var matchedSkuId = commonSkuIds[0]
    // 从任一组的选项中获取完整的 SKU 信息
    for (var p = 0; p < this.specGroups.length; p++) {
      var opts = this.specGroups[p].options
      for (var q = 0; q < opts.length; q++) {
        if (opts[q].skuId === matchedSkuId) {
          this.selectedSku = {
            id: matchedSkuId,
            price: opts[q].price,
            stock: opts[q].stock
          }
          return
        }
      }
    }
  }
  this.selectedSku = null
},

buildSpecDesc: function() {
  if (!this.selectedSku) return ''
  var desc = ''
  for (var i = 0; i < this.specGroups.length; i++) {
    var group = this.specGroups[i]
    var optionId = this.selectedSpecs[group.groupId]
    var optionName = ''
    for (var j = 0; j < group.options.length; j++) {
      if (group.options[j].id === optionId) {
        optionName = group.options[j].name
        break
      }
    }
    if (desc) desc += '; '
    desc += group.groupName + ':' + optionName
  }
  return desc
}

任务 2:product-detail.vue — 模板层改造

文件:

  • 修改:cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

  • [ ] 步骤 1:修改价格展示区

将 info-section 中的 price-row 替换为:

<view class="price-row">
  <text class="price">{{ formatPriceWithSymbol(displayPrice) }}</text>
  <view v-if="displayMemberPrice && displayMemberPrice > 0 && displayMemberPrice !== displayPrice" class="member-price-row">
    <text v-if="isMember" class="member-price-original">
      <text class="original-price">{{ formatPriceWithSymbol(displayPrice) }}</text>
    </text>
    <text v-if="!isMember" class="member-price-hint" @click="goUpgrade">
      会员价 {{ formatPriceWithSymbol(displayMemberPrice) }}  → 点击升级
    </text>
    <text v-if="isMember" class="member-price-current">
      会员价 {{ formatPriceWithSymbol(displayMemberPrice) }}
    </text>
  </view>
</view>
  • 步骤 2:添加 SKU 选择器模板

在 info-section 结束标签 </view> 之后、intro-section 之前插入:

<!-- SKU 选择器 -->
<view v-if="specGroups.length > 0" class="sku-section">
  <view v-for="group in specGroups" :key="group.groupId" class="spec-group">
    <text class="spec-group-name">{{ group.groupName }}</text>
    <view class="spec-options">
      <view
        v-for="option in group.options"
        :key="option.id"
        :class="['spec-option', getSpecOptionClass(group.groupId, option)]"
        @click="selectSpec(group.groupId, option)"
      >
        <text class="spec-option-text">{{ option.name }}</text>
      </view>
    </view>
  </view>
  <view v-if="selectedSku" class="selected-summary">
    <text class="selected-text">已选: {{ buildSpecDesc() }}</text>
  </view>
</view>
  • 步骤 3:修改底部栏联动

将 bottom-bar 中的价格和按钮替换为:

<view class="bottom-bar">
  <view class="price-info">
    <text class="bottom-price">{{ formatPriceWithSymbol(displayPrice) }}</text>
    <text v-if="displayMemberPrice && displayMemberPrice > 0 && !isMember" class="bottom-member">会员{{ formatPriceWithSymbol(displayMemberPrice) }}</text>
    <text v-if="displayMemberPrice && displayMemberPrice > 0 && isMember" class="bottom-member bottom-member-highlight">会员{{ formatPriceWithSymbol(displayMemberPrice) }}</text>
  </view>
  <view class="action-btns">
    <view class="action-extra">
      <button class="btn-share" @click="onShareProduct">🖼</button>
    </view>
    <view class="action-extra">
      <button class="btn-cart" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onAddToCart">{{ buyButtonDisabled ? '请选规格' : '加入购物车' }}</button>
    </view>
    <button class="btn-buy" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onBuy">{{ buyButtonText }}</button>
  </view>
</view>
  • 步骤 4:添加样式

在 <style scoped> 中添加:

/* SKU 选择器 */
.sku-section {
  background: #fff;
  padding: 20rpx 30rpx;
  margin-bottom: 20rpx;
}
.spec-group {
  margin-bottom: 20rpx;
}
.spec-group:last-child {
  margin-bottom: 10rpx;
}
.spec-group-name {
  font-size: 26rpx;
  color: #666;
  margin-bottom: 12rpx;
  display: block;
}
.spec-options {
  display: flex;
  flex-wrap: wrap;
}
.spec-option {
  padding: 12rpx 28rpx;
  border: 2rpx solid #ddd;
  border-radius: 8rpx;
  margin-right: 16rpx;
  margin-bottom: 12rpx;
  background: #fff;
}
.spec-option.spec-option-selected {
  border-color: #F97316;
  background: #F97316;
}
.spec-option.spec-option-selected .spec-option-text {
  color: #fff;
}
.spec-option.spec-option-disabled {
  border-color: #eee;
  background: #f9f9f9;
  opacity: 0.5;
}
.spec-option-text {
  font-size: 26rpx;
  color: #333;
}
.selected-summary {
  margin-top: 10rpx;
  padding-top: 14rpx;
  border-top: 1rpx solid #f0f0f0;
}
.selected-text {
  font-size: 24rpx;
  color: #F97316;
}

/* 会员价 */
.member-price-row {
  margin-top: 6rpx;
}
.member-price-hint {
  font-size: 22rpx;
  color: #F97316;
  text-decoration: underline;
}
.member-price-original {
  font-size: 22rpx;
  color: #999;
}
.member-price-current {
  font-size: 22rpx;
  color: #F97316;
  font-weight: bold;
}
.original-price {
  text-decoration: line-through;
}
.bottom-member-highlight {
  color: #F97316 !important;
}
.btn-disabled {
  opacity: 0.5;
}
  • 步骤 5:添加 getSpecOptionClass 方法

在 methods 中添加:

getSpecOptionClass: function(groupId, option) {
  var isSelected = this.selectedSpecs[groupId] === option.id
  var isDisabled = !option.enabled || (option.stock !== undefined && option.stock <= 0)
  if (isSelected) return 'spec-option spec-option-selected'
  if (isDisabled) return 'spec-option spec-option-disabled'
  return 'spec-option'
}
  • 步骤 6:添加 goUpgrade 方法

在 methods 中添加:

goUpgrade: function() {
  uni.navigateTo({ url: '/pages/membership/upgrade' })
}

任务 3:product-detail.vue — 修改加购和购买逻辑

文件:

  • 修改:cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

  • [ ] 步骤 1:修改 onAddToCart 传 skuId

    onAddToCart: function() {
    if (this.buyButtonDisabled) {
    uni.showToast({ title: '请选择完整规格', icon: 'none' })
    return
    }
    var userId = uni.getStorageSync('userId')
    if (!userId) {
    uni.navigateTo({ url: '/pages/login/login' })
    return
    }
    uni.showLoading({ title: '加入购物车...' })
    var params = {
    userId: userId,
    productId: this.product.id,
    quantity: 1
    }
    if (this.selectedSku) {
    params.skuId = this.selectedSku.id
    }
    cartAdd(params).then(function(res) {
    uni.hideLoading()
    if (res.code === 200) {
      uni.showToast({ title: '已加入购物车', icon: 'success' })
    } else {
      uni.showToast({ title: res.message || '加入购物车失败', icon: 'none' })
    }
    }).catch(function() {
    uni.hideLoading()
    uni.showToast({ title: '网络异常', icon: 'none' })
    })
    }
    
  • [ ] 步骤 2:修改 onBuy 传 skuId 和 specDesc

    onBuy: function() {
    if (this.buyButtonDisabled) {
    uni.showToast({ title: '请选择完整规格', icon: 'none' })
    return
    }
    var userId = uni.getStorageSync('userId')
    if (!userId) {
    uni.navigateTo({ url: '/pages/login/login' })
    return
    }
    var price = this.selectedSku ? this.selectedSku.price : this.product.price
    var productData = {
    productId: this.product.id,
    productName: this.product.name,
    coverImage: this.product.coverImage,
    unitPrice: price,
    quantity: 1
    }
    if (this.selectedSku) {
    productData.skuId = this.selectedSku.id
    productData.specDesc = this.buildSpecDesc()
    }
    uni.setStorageSync('checkoutItems', [productData])
    uni.navigateTo({
    url: '/pages/shop/checkout/checkout?from=product&id=' + this.product.id
    })
    }
    

任务 4:cart.vue — 结算时传递 skuId 和 specDesc

文件:

  • 修改:cfc-frontend/pages/shop/cart/cart.vue

  • [ ] 步骤 1:修改 goCheckout 传 skuId 和 specDesc

    goCheckout: function() {
    if (this.selectedCount === 0) {
    uni.showToast({ title: '请选择商品', icon: 'none' })
    return
    }
    var selected = []
    for (var i = 0; i < this.items.length; i++) {
    if (this.items[i].checked) {
      var item = {
        productId: this.items[i].productId,
        productName: this.items[i].productName,
        coverImage: this.items[i].coverImage,
        unitPrice: this.items[i].unitPrice,
        quantity: this.items[i].quantity
      }
      // 传递 SKU 信息
      if (this.items[i].skuId) {
        item.skuId = this.items[i].skuId
      }
      if (this.items[i].specDesc) {
        item.specDesc = this.items[i].specDesc
      }
      selected.push(item)
    }
    }
    uni.setStorageSync('checkoutItems', selected)
    uni.navigateTo({ url: '/pages/shop/checkout/checkout' })
    }
    

任务 5:验证

  • [ ] 步骤 1:编译验证

    cd /app/cfc/cfc-frontend && npm run build:mp-weixin
    

预期:无编译错误,dist 目录生成成功

  • 步骤 2:代码审查

检查以下要点:

  1. specMap 返回空时,SKU 选择器不显示,不影响无 SKU 商品
  2. 未选完整规格时,按钮显示"请选择完整规格"并禁用
  3. 会员价展示逻辑正确(会员/非会员区分)
  4. 加购时传 skuId
  5. 购买时传 skuId 和 specDesc
  6. 购物车结算时传 skuId 和 specDesc
  7. 无可选链、CSS Grid 等微信小程序禁止语法