面向 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
文件:
修改: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 // 会员状态
}
}
在 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 '立即购买'
}
}
修改 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
})
}
在 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()
}
}
在 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
}
文件:
修改: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>
在 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>
将 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>
在 <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;
}
在 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'
}
在 methods 中添加:
goUpgrade: function() {
uni.navigateTo({ url: '/pages/membership/upgrade' })
}
文件:
修改: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
})
}
文件:
修改: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' })
}
[ ] 步骤 1:编译验证
cd /app/cfc/cfc-frontend && npm run build:mp-weixin
预期:无编译错误,dist 目录生成成功
检查以下要点: