Explorar o código

docs: 添加商品SKU选择器设计文档和实现计划

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Xiaogang Liao hai 1 mes
pai
achega
6c07c03ae7

+ 583 - 0
docs/superpowers/plans/2026-08-04-product-sku-selector.md

@@ -0,0 +1,583 @@
+# 商品 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`:
+
+```js
+import { productDetail, productOrderCreate, productOrderPay, cartAdd, getShareQrCode, productSpecMap, getMyMembership } from '@/utils/api.js'
+```
+
+在 `data()` 中添加:
+
+```js
+data() {
+  return {
+    product: {},
+    loading: false,
+    showPoster: false,
+    posterQrCode: '',
+    posterDimension: '',
+    posterInviteText: '',
+    // SKU 选择器状态
+    specGroups: [],       // 规格分组数据
+    selectedSpecs: {},    // { groupId: optionId }
+    selectedSku: null,    // 匹配到的完整 SKU
+    isMember: false       // 会员状态
+  }
+}
+```
+
+- [ ] **步骤 2:添加计算属性**
+
+在 `computed` 中添加:
+
+```js
+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` 在加载商品详情后获取规格数据:
+
+```js
+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` 中添加:
+
+```js
+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`:
+
+```js
+onLoad: function(options) {
+  // ... 现有代码 ...
+  if (pid) {
+    this.loadDetail(pid)
+    this.checkMemberStatus()
+  }
+}
+```
+
+- [ ] **步骤 5:添加规格选择方法**
+
+在 `methods` 中添加:
+
+```js
+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 替换为:
+
+```html
+<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` 之前插入:
+
+```html
+<!-- 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` 中的价格和按钮替换为:
+
+```html
+<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>` 中添加:
+
+```css
+/* 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` 中添加:
+
+```js
+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` 中添加:
+
+```js
+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**
+
+```js
+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**
+
+```js
+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**
+
+```js
+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:编译验证**
+
+```bash
+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 等微信小程序禁止语法

+ 220 - 0
docs/superpowers/specs/2026-08-04-product-sku-selector-design.md

@@ -0,0 +1,220 @@
+# 商品 SKU 选择器设计
+
+## 概述
+
+在商品详情页添加 SKU(规格)选择功能,让用户在加购或下单前选择商品规格。后端和购物车/结算链路已支持 SKU,主要工作在前端。
+
+## 涉及文件
+
+| 文件 | 改动 |
+|------|------|
+| `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | 新增 SKU 选择器 UI + 修改加购/购买逻辑 |
+| `cfc-frontend/pages/shop/cart/cart.vue` | 结算时传递 skuId + specDesc |
+| `cfc-frontend/utils/api.js` | 已有 `productSpecMap`,无需新增 |
+
+## 数据流
+
+### 加载时
+
+```
+loadDetail(id)
+  ├── productDetail({ id })          → 商品基本信息
+  └── productSpecMap({ productIds: [id] })  → 规格分组
+```
+
+### 规格选择时
+
+```
+用户选择规格选项
+  → 记录 selectedSpecs: { groupId → optionId }
+  → 遍历 SKU 列表匹配当前选择组合
+  → 找到匹配 SKU → 更新价格/库存/图片
+  → 未完全选择 → 提示"请选择完整规格"
+```
+
+### 提交时
+
+```
+onAddToCart()
+  → cartAdd({ userId, productId, skuId, quantity: 1 })
+
+onBuy()
+  → checkoutItems = [{ productId, productName, coverImage, skuId, specDesc, unitPrice, quantity }]
+  → 存入 storage → 跳转 checkout
+
+goCheckout() (cart.vue)
+  → checkoutItems = [{ ..., skuId, specDesc }]
+  → 存入 storage → 跳转 checkout
+```
+
+## 数据结构
+
+### 从 specMap 接口获取
+
+```js
+// /api/product/spec/map
+// 入参: { productIds: [123] }
+// 出参: { "123": [ ...groups ] }
+specGroups: [
+  {
+    groupId: 1,
+    groupName: '颜色',
+    options: [
+      { id: 101, name: '红色', skuId: 1, price: 10000, stock: 10, enabled: 1 },
+      { id: 102, name: '蓝色', skuId: 2, price: 12000, stock: 5, enabled: 1 }
+    ]
+  },
+  {
+    groupId: 2,
+    groupName: '尺码',
+    options: [
+      { id: 201, name: 'M', skuId: 1, price: 10000, stock: 10, enabled: 1 },
+      { id: 202, name: 'L', skuId: 2, price: 12000, stock: 5, enabled: 1 }
+    ]
+  }
+]
+```
+
+### 组件状态
+
+```js
+data() {
+  return {
+    product: {},
+    specGroups: [],           // 从 specMap 获取
+    selectedSpecs: {},        // { groupId: optionId }
+    selectedSku: null,        // 匹配到的完整 SKU
+    skuList: [],              // 从 specMap 解析出的所有 SKU 映射
+    isMember: false           // 会员状态
+  }
+}
+```
+
+### SKU 匹配逻辑
+
+```js
+// 从 specMap 数据中构建 SKU 映射
+// 每个 option 都带有 skuId,同一 skuId 跨 group 出现
+// 当选完所有组后,任一选项的 skuId 即为匹配结果
+
+matchSku: function() {
+  var groups = this.specGroups
+  var selected = this.selectedSpecs
+  // 检查是否所有组都已选
+  for (var i = 0; i < groups.length; i++) {
+    if (!selected[groups[i].groupId]) return null
+  }
+  // 取第一个选中选项的 skuId(同组所有选项的 skuId 指向同一 SKU)
+  var firstGroup = groups[0]
+  var firstOptionId = selected[firstGroup.groupId]
+  var skuId = null
+  for (var j = 0; j < firstGroup.options.length; j++) {
+    if (firstGroup.options[j].id === firstOptionId) {
+      skuId = firstGroup.options[j].skuId
+      break
+    }
+  }
+  return skuId
+}
+```
+
+## UI 设计
+
+### 页面布局
+
+```
+┌─────────────────────────────────┐
+│  [轮播图]                        │
+├─────────────────────────────────┤
+│  商品名称                        │
+│  ¥100.00                        │  ← 主价格(大号橙色)
+│  会员价 ¥80.00  → 点击升级      │  ← 非会员: 小字灰色,可点击
+│  ─ 或 ─                         │
+│  ¥80.00                         │  ← 会员: 主价格显示会员价
+│  ¥100.00                        │  ← 会员: 原价带删除线
+│                                  │
+│  库存 50 件                       │
+│  供应商:XXX                      │
+├─────────────────────────────────┤
+│  颜色                            │  ← 规格组名
+│  ┌──────┐ ┌──────┐ ┌──────┐    │
+│  │ ■红色 │ │ 蓝色  │ │ 黑色  │    │  ← 选中态/未选态/不可选态
+│  └──────┘ └──────┘ └──────┘    │
+│                                  │
+│  尺码                            │
+│  ┌──────┐ ┌──────┐ ┌──────┐    │
+│  │  S   │ │ ■M  │ │  L   │    │
+│  └──────┘ └──────┘ └──────┘    │
+│                                  │
+│  已选: 红色 / M                  │
+├─────────────────────────────────┤
+│  商品介绍...                     │
+├─────────────────────────────────┤
+│  商品详情...                     │
+├─────────────────────────────────┤
+│  ¥100.00  [🖼][加入购物车][立即购买]  │
+└─────────────────────────────────┘
+```
+
+### 规格按钮状态
+
+| 状态 | 样式 | 条件 |
+|------|------|------|
+| 未选 | 白底灰边框 #ddd | 默认 |
+| 选中 | 橙底白字 #F97316 | 已点击选中 |
+| 不可选 | 灰色+删除线 | 对应 SKU 库存为 0 或未启用 |
+
+### 价格联动
+
+- **无 SKU 的商品**:不显示 SKU 选择器,保持现有行为
+- **有 SKU 但未选完整**:底部栏显示"请选择完整规格",按钮禁用
+- **有 SKU 且已选完整**:显示 SKU 价格,按钮启用
+- **非会员**:主价格显示原价,小字显示会员价(可点击跳转 `/pages/membership/upgrade`)
+- **会员**:主价格显示会员价,小字显示原价(带删除线)
+
+## 修改点汇总
+
+### 1. product-detail.vue
+
+**新增导入:** `productSpecMap`, `getMyMembership`
+
+**新增 data:**
+- `specGroups: []`
+- `selectedSpecs: {}`
+- `selectedSku: null`
+- `isMember: false`
+
+**loadDetail 中补充:** 调用 `productSpecMap` 获取规格数据
+
+**新增模板:** 在 `info-section` 和 `intro-section` 之间插入规格选择区域
+
+**修改 onAddToCart:** 传 `skuId`
+
+**修改 onBuy:** 传 `skuId` 和 `specDesc`
+
+**新增方法:**
+- `selectSpec(groupId, option)` — 选择规格
+- `matchSku()` — 匹配当前选择的 SKU
+- `buildSpecDesc()` — 构建规格描述文本
+- `checkMemberStatus()` — 检查会员状态
+
+### 2. cart.vue
+
+**修改 goCheckout:** 传 `skuId` 和 `specDesc` 到 `checkoutItems`
+
+## 边界情况
+
+| 场景 | 行为 |
+|------|------|
+| 商品无 SKU(specMap 返回空) | 不显示 SKU 选择器,保持现有行为 |
+| 部分规格选项库存为 0 | 该选项显示为灰色不可选 |
+| 选择组合后无对应 SKU | 提示"该规格组合暂无库存" |
+| 用户未选规格直接点加购 | 提示"请选择完整规格" |
+| 非会员查看会员价 | 会员价小字显示,可点击跳转升级页 |
+| 会员查看 | 主价格显示会员价,原价带删除线 |
+
+## 不涉及
+
+- 后端无改动(已有完整 SKU 支持)
+- 结算页无改动(已有 `skuId` 处理逻辑)
+- 订单详情页无改动