面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 将会员开通/续费链路拆分为「套餐选择 upgrade → 订单确认 pay → 支付结果 result」三段式,并支持会员价格「原价/划线价」配置展示。
架构: 后端仅增强 /api/membership/plans 返回原价字段 + sys_config 新增 4 个原价键(零表结构变更);前端新增 pay.vue、result.vue,改造 upgrade.vue(移除支付逻辑),api.js 修复 createOrder 透传 couponId。
技术栈: Spring Boot 2.7.18 + MyBatis-Plus(后端)/ uni-app Vue 2 Options API(小程序前端)/ 微信虚拟支付 requestVirtualPayment
设计文档: docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md
验证方式(本项目约定):
cd cfc-backend && mvn clean compile(唯一验证方式,不做运行时验证)<script> 块后 node --check 做语法校验;禁止执行 npm run build:mp-weixin(必须用 HBuilderX 打包,Agent 不做)| 文件 | 动作 | 职责 |
|---|---|---|
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java |
修改 | SysConfig 种子区加 4 个原价键(幂等) |
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java |
修改 | getPricingPlans() 增加 originalYearly/originalMonthly/originalQuarterly |
cfc-frontend/utils/api.js |
修改 | createOrder 增加 couponId 透传(第 4 参数) |
cfc-frontend/pages/membership/upgrade.vue |
修改 | 移除支付/优惠券逻辑;按钮改「下一步」;修会员状态判断 bug;价格区划线原价 |
cfc-frontend/pages/membership/pay.vue |
创建 | 订单确认:商品明细 + 优惠券 Picker + 拆单提示 + 支付调用 |
cfc-frontend/pages/membership/result.vue |
创建 | 支付结果:成功态(赠券)/失败态(续付入口) |
cfc-frontend/pages.json |
修改 | pages/membership 分包注册 pay、result 两页 |
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(SysConfig 种子区,约第 1040 行 member_fee_premium_monthly 之后)
[ ] 步骤 1:在 SysConfig 种子区添加 4 个原价键
在 insertSysConfigSeed("member_fee_premium_monthly", "131400", "高级家庭会员月费(分)"); 这一行之后追加:
insertSysConfigSeed("member_fee_family_original_yearly", "199900", "家庭会员年费原价(分)");
insertSysConfigSeed("member_fee_family_original", "199900", "家庭会员原价兜底(分)");
insertSysConfigSeed("member_fee_premium_original", "1599900", "高级会员年费原价(分)");
insertSysConfigSeed("member_fee_premium_original_monthly", "159900", "高级会员月费原价(分)");
注意缩进与相邻行一致(insertSysConfigSeed 调用在 try 块内,前导 12 空格)。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(无编译错误)
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
git commit -m "feat: 会员原价配置(sys_config 新增4键: family/premium 年费月费原价)"
/api/membership/plans 增加原价字段文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java(getPricingPlans() 方法,第 317-348 行)
[ ] 步骤 1:修改 getPricingPlans() 增加原价字段
将 getPricingPlans() 中三个价格分支各补充原价字段:
// 付费等级价格从 sys_config 读取
if ("FAMILY".equals(level.getLevelCode())) {
// 家庭会员仅支持年付
String yearlyStr = sysConfigService.getValue("member_fee_family_yearly");
if (yearlyStr == null) yearlyStr = sysConfigService.getValue("member_fee_family");
plan.put("yearly", yearlyStr != null ? Integer.parseInt(yearlyStr) : 131400);
plan.put("monthly", null);
plan.put("quarterly", null);
// 原价(划线价,仅展示)
String origYearlyStr = sysConfigService.getValue("member_fee_family_original_yearly");
if (origYearlyStr == null) origYearlyStr = sysConfigService.getValue("member_fee_family_original");
plan.put("originalYearly", origYearlyStr != null ? Integer.parseInt(origYearlyStr) : null);
plan.put("originalMonthly", null);
plan.put("originalQuarterly", null);
} else if ("PREMIUM".equals(level.getLevelCode())) {
String yearlyStr = sysConfigService.getValue("member_fee_premium");
String monthlyStr = sysConfigService.getValue("member_fee_premium_monthly");
plan.put("yearly", yearlyStr != null ? Integer.parseInt(yearlyStr) : 1316800);
plan.put("monthly", monthlyStr != null ? Integer.parseInt(monthlyStr) : 131400);
plan.put("quarterly", 0);
// 原价(划线价,仅展示)
String origYearlyStr = sysConfigService.getValue("member_fee_premium_original");
String origMonthlyStr = sysConfigService.getValue("member_fee_premium_original_monthly");
plan.put("originalYearly", origYearlyStr != null ? Integer.parseInt(origYearlyStr) : null);
plan.put("originalMonthly", origMonthlyStr != null ? Integer.parseInt(origMonthlyStr) : null);
plan.put("originalQuarterly", null);
} else {
plan.put("monthly", level.getPriceMonthly());
plan.put("quarterly", level.getPriceQuarterly());
plan.put("yearly", level.getPriceYearly());
// 其他等级无原价定义
plan.put("originalYearly", null);
plan.put("originalMonthly", null);
plan.put("originalQuarterly", null);
}
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
git commit -m "feat: /api/membership/plans 返回原价字段(originalYearly/Monthly/Quarterly)"
文件:
修改:cfc-frontend/utils/api.js(第 369-375 行 createOrder)
[ ] 步骤 1:修改 createOrder 增加 couponId 参数
export const createOrder = (levelCode, paymentType, period, couponId) => {
var data = { levelCode: levelCode, paymentType: paymentType }
if (period) {
data.period = period
}
if (couponId) {
data.couponId = couponId
}
return request('/api/membership/orders', 'POST', data)
}
(保持现有调用 createOrder(levelCode, 'pay', period) 兼容,couponId 可选)
运行:node -e "const fs=require('fs');const s=fs.readFileSync('cfc-frontend/utils/api.js','utf8');const m=s.match(/export const createOrder =[\s\S]*?\n}/);if(!m){console.error('createOrder not found');process.exit(1)};new Function(m[0].replace(/^export const /,'const ').replace(/^const /,'const '));console.log('createOrder syntax OK')"
预期:createOrder syntax OK
[ ] 步骤 3:Commit
git add cfc-frontend/utils/api.js
git commit -m "fix: createOrder 透传 couponId(会员优惠券此前未生效)"
文件:
cfc-frontend/pages/membership/upgrade.vue(整文件)要点: 移除优惠券选择 + 支付调用 + 支付结果轮询;底部主按钮改「下一步」跳转 pay;修复会员状态判断 bug(m.memberLevel 而非 m.levelCode);价格区展示划线原价。
模板中两处(开通卡片 + 续费卡片)的优惠券行删除,按钮改为:
<!-- 开通卡片 -->
<button class="btn-upgrade" @click="goNext">下一步</button>
...
<!-- 续费卡片 -->
<button class="btn-upgrade btn-renew" @click="goNext">下一步</button>
删除整个 coupon-row 块(两处)和底部 modal-mask(Coupon Picker Bottom Sheet)整块。
script 变更:
createOrder、getCouponList、requestVirtualPayment、nextOrdercoupons、selectedCoupon、showCouponPicker、joinRules(joinRules 保留,开通赠券仍展示)openCouponPicker、selectCoupon、removeCoupon、getCouponCondition、handleUpgrade、doUpgrade、pollOrderStatus、payInstallments、handleRenew、doRenew新增 goNext:
goNext: function() {
var levelCode = this.selectedLevel
var period = this.selectedPeriod
uni.navigateTo({ url: '/pages/membership/pay?levelCode=' + levelCode + '&period=' + period })
},
新增划线原价 computed:
computed: {
visiblePeriods() { /* 保留现有 */ },
availableCoupons() { /* 删除此 computed */ }
},
在 methods 中新增原价查询方法(替代 availableCoupons 的位置):
getOriginalPriceForPeriod: function(periodKey) {
var plan = null
for (var i = 0; i < this.levels.length; i++) {
if (this.levels[i].levelCode === this.selectedLevel) {
plan = this.levels[i]
break
}
}
if (!plan) return null
if (periodKey === 'monthly') return plan.originalMonthly || null
if (periodKey === 'quarterly') return plan.originalQuarterly || null
if (periodKey === 'yearly') return plan.originalYearly || null
return null
},
showOriginalPrice: function(periodKey) {
var original = this.getOriginalPriceForPeriod(periodKey)
var current = this.getPriceForPeriod(periodKey)
if (!original || !current) return false
return original > current * 100
},
修复会员状态判断 bug:loadData() 中:
if (this.membership && this.membership.levelCode && this.membership.levelCode !== 'FREE') {
改为:
if (this.membership && this.membership.memberLevel && this.membership.memberLevel !== 'FREE') {
并同步下方 this.selectedLevel = this.membership.levelCode → this.membership.memberLevel,this.currentLevelName 取值逻辑不变(m.memberLevel 时用 levelName 映射或保留 '家庭会员')。
在样式区追加:
/* ===== 划线原价 ===== */
.period-original {
font-size: 20rpx;
color: #94A3B8;
text-decoration: line-through;
margin-top: 2rpx;
}
.price-original-line {
font-size: 26rpx;
color: #94A3B8;
text-decoration: line-through;
margin-right: 12rpx;
}
period-tab 内价格下方(period-tab-price 之后)加:
<text class="period-original" v-if="showOriginalPrice(p.key)">¥{{ getOriginalPriceForPeriod(p.key) / 100 }}</text>
升级价格大数字旁(price-unit 之前)加:
<text class="price-original-line" v-if="showOriginalPrice(selectedPeriod)">¥{{ getOriginalPriceForPeriod(selectedPeriod) / 100 }}</text>
提取 script 块运行 node --check(用 node 脚本提取 <script> 与 </script> 之间的内容,写临时文件后 node --check)。
预期:无语法错误。
[ ] 步骤 6:Commit
git add cfc-frontend/pages/membership/upgrade.vue
git commit -m "refactor: upgrade页移除支付/优惠券逻辑,改下一步跳转pay,修复会员状态判断bug,支持划线原价"
文件:
cfc-frontend/pages/membership/pay.vue要点: 接收 levelCode/period 参数 → plans 取价(含原价)→ couponList 取券 → 展示明细 + 优惠券 Picker → createOrder(couponId) → 微信虚拟支付(拆单逐期)→ 跳转 result。
[ ] 步骤 1:创建 pay.vue 完整文件
<template>
<view class="pay-page">
<scroll-view class="page" scroll-y>
<!-- 商品明细卡 -->
<view class="order-card">
<view class="order-header">
<text class="order-title">{{ levelName }} · {{ periodLabel }}</text>
<text class="order-sub">{{ levelCode }}</text>
</view>
<view class="price-row" v-if="showOriginal">
<text class="price-label">原价</text>
<text class="price-original">¥{{ originalPriceText }}</text>
</view>
<view class="price-row">
<text class="price-label">现价</text>
<text class="price-now">¥{{ priceText }}</text>
</view>
<view class="coupon-row" @click="openCouponPicker">
<text class="coupon-row-label">优惠券</text>
<text v-if="selectedCoupon" class="coupon-row-discount">-¥{{ couponDiscountText }} {{ selectedCoupon.name }}</text>
<text v-else class="coupon-row-placeholder">选择优惠券</text>
<text class="coupon-row-arrow">›</text>
</view>
<view class="divider"></view>
<view class="total-row">
<text class="total-label">实付</text>
<text class="total-amount">¥{{ finalPriceText }}</text>
</view>
</view>
<!-- 拆单提示卡 -->
<view class="split-card" v-if="totalPeriods > 1">
<text class="split-title">💡 本订单将分 {{ totalPeriods }} 期支付</text>
<text class="split-desc">每期 ¥{{ splitAmountText }},全部支付完成后自动开通会员</text>
</view>
<!-- 支付进度(拆单逐期时显示) -->
<view class="progress-card" v-if="paying && totalPeriods > 1">
<text class="progress-text">第 {{ currentPeriod }} / {{ totalPeriods }} 期</text>
</view>
</scroll-view>
<!-- 底部固定支付栏 -->
<view class="pay-bar">
<button class="btn-pay" :loading="paying" :disabled="paying" @click="handlePay">
{{ paying ? '支付中…' : '立即支付 ¥' + finalPriceText }}
</button>
</view>
<!-- Coupon Picker Bottom Sheet -->
<view class="modal-mask" v-if="showCouponPicker" @click="showCouponPicker = false">
<view class="coupon-picker" @click.stop>
<view class="picker-header">
<text class="picker-title">选择优惠券</text>
<text class="picker-close" @click="showCouponPicker = false">关闭</text>
</view>
<scroll-view scroll-y class="picker-list">
<view
v-for="coupon in availableCoupons"
:key="coupon.id"
class="picker-item"
:class="selectedCoupon && selectedCoupon.id === coupon.id ? 'picker-item-active' : ''"
@click="selectCoupon(coupon)"
>
<view class="picker-item-left">
<text class="picker-item-value">{{ formatPrice(coupon.value) }}</text>
</view>
<view class="picker-item-right">
<text class="picker-item-name">{{ coupon.name }}</text>
<text class="picker-item-condition">{{ getCouponCondition(coupon.minSpend) }}</text>
</view>
<view class="picker-item-check" v-if="selectedCoupon && selectedCoupon.id === coupon.id">
<text>✓</text>
</view>
</view>
<view class="picker-empty" v-if="availableCoupons.length === 0">
<text>暂无可用优惠券</text>
</view>
</scroll-view>
<view class="picker-footer" v-if="selectedCoupon">
<button class="picker-btn-remove" @click="removeCoupon">不使用优惠券</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { getMembershipPlans, getCouponList, createOrder, requestVirtualPayment, nextOrder } from '../../utils/api.js'
export default {
data() {
return {
levelCode: 'FAMILY',
period: 'yearly',
levelName: '家庭会员',
periodLabel: '年度',
levels: [],
coupons: [],
selectedCoupon: null,
showCouponPicker: false,
originalPrice: null,
price: 0,
totalPeriods: 1,
currentPeriod: 1,
splitAmount: 0,
orderNo: null,
paying: false
}
},
computed: {
showOriginal: function() {
return this.originalPrice != null && this.originalPrice > this.price
},
priceText: function() {
return (this.price / 100).toFixed(2)
},
originalPriceText: function() {
if (this.originalPrice == null) return '0.00'
return (this.originalPrice / 100).toFixed(2)
},
couponDiscount: function() {
if (!this.selectedCoupon) return 0
if (this.selectedCoupon.type === 'DISCOUNT') {
var rate = this.selectedCoupon.discountRate || 100
return Math.round(this.price * (100 - rate) / 100)
}
return this.selectedCoupon.value || 0
},
couponDiscountText: function() {
return (this.couponDiscount / 100).toFixed(2)
},
finalPrice: function() {
return Math.max(0, this.price - this.couponDiscount)
},
finalPriceText: function() {
return (this.finalPrice / 100).toFixed(2)
},
splitAmountText: function() {
return (this.splitAmount / 100).toFixed(2)
},
availableCoupons: function() {
var self = this
return this.coupons.filter(function(c) {
if (c.status && c.status !== 'AVAILABLE') return false
if (c.applicableTo && c.applicableTo !== 'ALL' && c.applicableTo !== 'MEMBERSHIP') return false
return true
})
}
},
onLoad: function(options) {
this.levelCode = options.levelCode || 'FAMILY'
this.period = options.period || 'yearly'
this.periodLabel = this.getPeriodLabel(this.period)
this.loadPlans()
this.loadCoupons()
},
methods: {
getPeriodLabel: function(period) {
var map = { monthly: '月度', quarterly: '季度', yearly: '年度' }
return map[period] || '年度'
},
loadPlans: function() {
var self = this
getMembershipPlans().then(function(res) {
var levels = res.data || []
for (var i = 0; i < levels.length; i++) {
if (levels[i].levelCode === self.levelCode) {
self.levelName = levels[i].levelName || self.levelName
if (self.period === 'monthly') {
self.price = levels[i].monthly || 0
self.originalPrice = levels[i].originalMonthly || null
} else if (self.period === 'quarterly') {
self.price = levels[i].quarterly || 0
self.originalPrice = levels[i].originalQuarterly || null
} else {
self.price = levels[i].yearly || 0
self.originalPrice = levels[i].originalYearly || null
}
break
}
}
}).catch(function(e) {
uni.showToast({ title: '加载价格失败', icon: 'none' })
console.error('加载价格失败', e)
})
},
loadCoupons: function() {
var self = this
getCouponList().then(function(res) {
self.coupons = res.data || []
}).catch(function() {})
},
openCouponPicker: function() {
if (this.availableCoupons.length === 0 && !this.selectedCoupon) {
uni.showToast({ title: '暂无可用优惠券', icon: 'none' })
return
}
this.showCouponPicker = true
},
selectCoupon: function(coupon) {
this.selectedCoupon = coupon
this.showCouponPicker = false
},
removeCoupon: function() {
this.selectedCoupon = null
this.showCouponPicker = false
},
getCouponCondition: function(minSpend) {
if (!minSpend || minSpend <= 0) return '无门槛'
return '满' + (minSpend / 100).toFixed(2) + '元可用'
},
formatPrice: function(value) {
if (value == null) return '0.00'
return (value / 100).toFixed(2)
},
handlePay: function() {
var self = this
if (this.paying) return
this.paying = true
var couponId = this.selectedCoupon ? this.selectedCoupon.id : null
createOrder(this.levelCode, 'pay', this.period, couponId).then(function(res) {
var data = res.data || {}
if (data.orderInfo && data.sign) {
self.orderNo = data.orderNo || (data.orderInfo && data.orderInfo.outTradeNo)
self.totalPeriods = data.totalPeriods || 1
self.currentPeriod = data.currentPeriod || 1
self.splitAmount = data.orderInfo && data.orderInfo.goodsPrice ? data.orderInfo.goodsPrice : (self.finalPrice / self.totalPeriods)
self.payInstallments(data)
} else {
// testMode 或试用:模拟支付成功
self.orderNo = data.orderNo
uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
setTimeout(function() {
self.paying = false
uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
}, 800)
}
}).catch(function(e) {
self.paying = false
if (e.code === 46001) {
uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
} else {
uni.showToast({ title: e.message || '下单失败', icon: 'none' })
}
})
},
payInstallments: function(dto) {
var self = this
var current = dto.currentPeriod || 1
var total = dto.totalPeriods || 1
requestVirtualPayment(dto, {
success: function() {
if (current < total) {
uni.showToast({ title: '第' + current + '期支付成功', icon: 'none' })
nextOrder({ orderNo: dto.orderNo }).then(function(res) {
var nd = res.data
if (nd && nd.done) {
self.finishPay()
} else if (nd && nd.orderInfo && nd.sign) {
self.currentPeriod = nd.currentPeriod || 1
self.payInstallments(nd)
} else {
self.finishPay()
}
}).catch(function() {
self.finishPay()
})
} else {
self.finishPay()
}
},
fail: function(err) {
self.paying = false
var errCode = err && err.errCode
var msg = ''
if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录后支付'
else if (errCode === -15010) msg = '商品未发布,请联系客服'
else if (errCode === -15013) msg = '商品价格异常'
else if (errCode === -2) msg = '支付已取消'
else if (err && err.errMsg) msg = err.errMsg
var status = (errCode === -2 || errCode === -15007 || errCode === -15005) ? 'fail' : 'fail'
uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=' + status })
}
})
},
finishPay: function() {
var self = this
uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
setTimeout(function() {
self.paying = false
uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
}, 800)
}
}
}
</script>
<style scoped>
.pay-page {
min-height: 100vh;
background: #FFF7ED;
padding-bottom: 160rpx;
box-sizing: border-box;
}
.page {
padding: 30rpx;
}
/* ===== 商品明细卡 ===== */
.order-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 40rpx;
box-shadow: 0 4rpx 24rpx rgba(249, 115, 22, 0.08);
}
.order-header {
margin-bottom: 24rpx;
}
.order-title {
font-size: 34rpx;
font-weight: bold;
color: #1E293B;
display: block;
}
.order-sub {
font-size: 24rpx;
color: #94A3B8;
margin-top: 6rpx;
display: block;
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
}
.price-label {
font-size: 26rpx;
color: #64748B;
}
.price-original {
font-size: 28rpx;
color: #94A3B8;
text-decoration: line-through;
}
.price-now {
font-size: 36rpx;
font-weight: bold;
color: #F97316;
}
.coupon-row {
display: flex;
align-items: center;
padding: 24rpx 0;
border-top: 1rpx solid #f5f5f5;
margin-top: 16rpx;
}
.coupon-row:active {
opacity: 0.7;
}
.coupon-row-label {
font-size: 26rpx;
color: #666;
margin-right: 16rpx;
}
.coupon-row-discount {
flex: 1;
font-size: 26rpx;
color: #F97316;
font-weight: 600;
text-align: right;
}
.coupon-row-placeholder {
flex: 1;
font-size: 26rpx;
color: #ccc;
text-align: right;
}
.coupon-row-arrow {
font-size: 32rpx;
color: #ccc;
margin-left: 8rpx;
}
.divider {
height: 1rpx;
background: #F1F5F9;
margin: 16rpx 0;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.total-label {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
}
.total-amount {
font-size: 44rpx;
font-weight: bold;
color: #F97316;
}
/* ===== 拆单提示 ===== */
.split-card {
background: #FEF3C7;
border-radius: 20rpx;
padding: 24rpx;
margin-top: 30rpx;
}
.split-title {
font-size: 28rpx;
font-weight: 600;
color: #92400E;
display: block;
margin-bottom: 8rpx;
}
.split-desc {
font-size: 24rpx;
color: #B45309;
display: block;
}
/* ===== 支付进度 ===== */
.progress-card {
background: #FFFFFF;
border-radius: 20rpx;
padding: 20rpx;
margin-top: 20rpx;
text-align: center;
}
.progress-text {
font-size: 26rpx;
color: #F97316;
font-weight: 600;
}
/* ===== 底部支付栏 ===== */
.pay-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #FFFFFF;
padding: 20rpx 30rpx calc(20rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05);
}
.btn-pay {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
color: #fff;
font-size: 34rpx;
font-weight: 600;
border-radius: 48rpx;
border: none;
}
.btn-pay::after {
border: none;
}
.btn-pay[disabled] {
opacity: 0.7;
color: #fff;
}
/* ===== Coupon Picker(复用 upgrade 样式) ===== */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 999;
display: flex;
align-items: flex-end;
}
.coupon-picker {
background: #fff;
border-radius: 24rpx 24rpx 0 0;
width: 100%;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
}
.picker-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.picker-close {
font-size: 26rpx;
color: #999;
}
.picker-list {
max-height: 50vh;
padding: 0 20rpx;
}
.picker-item {
display: flex;
align-items: center;
padding: 24rpx 16rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.picker-item:active {
background: #fafafa;
}
.picker-item-active {
background: #FFF7ED;
}
.picker-item-left {
width: 120rpx;
text-align: center;
flex-shrink: 0;
}
.picker-item-value {
font-size: 32rpx;
font-weight: bold;
color: #F97316;
}
.picker-item-right {
flex: 1;
margin: 0 16rpx;
}
.picker-item-name {
font-size: 26rpx;
color: #333;
display: block;
margin-bottom: 4rpx;
}
.picker-item-condition {
font-size: 22rpx;
color: #999;
}
.picker-item-check {
width: 40rpx;
height: 40rpx;
background: #F97316;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
font-size: 24rpx;
font-weight: bold;
}
.picker-empty {
text-align: center;
padding: 60rpx 0;
font-size: 26rpx;
color: #999;
}
.picker-footer {
padding: 20rpx;
border-top: 1rpx solid #eee;
}
.picker-btn-remove {
background: #f5f5f5;
color: #666;
font-size: 26rpx;
border-radius: 12rpx;
border: none;
}
.picker-btn-remove::after {
border: none;
}
</style>
[ ] 步骤 2:语法校验
提取 script 块运行 node --check。
预期:无语法错误。
[ ] 步骤 3:Commit
git add cfc-frontend/pages/membership/pay.vue
git commit -m "feat: 新增订单确认页 pay.vue(明细/优惠券/拆单提示/虚拟支付逐期)"
文件:
cfc-frontend/pages/membership/result.vue要点: 读 orderNo/status → 成功态轮询确认开通 + 展示赠券;失败态展示原因 + 重新支付/续付入口(拆单未付清调 nextOrder)。
[ ] 步骤 1:创建 result.vue 完整文件
<template>
<view class="result-page">
<!-- 成功态 -->
<view class="result-body" v-if="status === 'success'">
<view class="result-icon success">
<text class="icon-text">✅</text>
</view>
<text class="result-title">开通成功</text>
<text class="result-sub">{{ levelName }} {{ levelCode }}</text>
<view class="info-card">
<view class="info-row">
<text class="info-label">有效期至</text>
<text class="info-value">{{ expireText }}</text>
</view>
<view class="info-row" v-if="giftCoupons.length > 0">
<text class="info-label">开通赠券</text>
<text class="info-value gift">{{ giftCouponsText }}</text>
</view>
</view>
<view class="btn-group">
<button class="btn-primary" @click="goBenefits">查看我的权益</button>
<button class="btn-secondary" @click="goHome">返回会员中心</button>
</view>
</view>
<!-- 失败态 -->
<view class="result-body" v-else>
<view class="result-icon fail">
<text class="icon-text">❌</text>
</view>
<text class="result-title">支付未完成</text>
<text class="result-sub">{{ failReason }}</text>
<view class="btn-group">
<button class="btn-primary" v-if="canContinue" @click="continuePay">继续支付</button>
<button class="btn-primary" v-else @click="retryPay">重新支付</button>
<button class="btn-secondary" @click="goUpgrade">返回套餐选择</button>
</view>
</view>
</view>
</template>
<script>
import { getMyMembership, getJoinCouponRules, nextOrder, requestVirtualPayment } from '../../utils/api.js'
import { parseDate } from '../../utils/format.js'
export default {
data() {
return {
orderNo: '',
status: 'success',
levelName: '会员',
levelCode: '',
expireText: '',
giftCoupons: [],
failReason: '支付未完成,请重试',
canContinue: false,
nextPayParams: null
}
},
computed: {
giftCouponsText: function() {
var self = this
return this.giftCoupons.map(function(r) {
return '「' + r.name + '」×' + (r.grantQuantity || 1)
}).join('、')
}
},
onLoad: function(options) {
this.orderNo = options.orderNo || ''
this.status = options.status || 'success'
if (this.status === 'success') {
this.loadSuccess()
} else {
this.loadFail()
}
},
methods: {
loadSuccess: function() {
var self = this
// 轮询确认开通
var times = 0
var timer = setInterval(function() {
times++
getMyMembership().then(function(res) {
var m = res.data || {}
var mem = m.membership
if (mem && mem.isActive) {
clearInterval(timer)
self.levelCode = mem.levelCode || ''
self.levelName = mem.levelName || self.levelName
self.expireText = self.formatDate(mem.endDate)
self.loadGiftCoupons()
} else if (times >= 8) {
clearInterval(timer)
self.loadGiftCoupons()
}
}).catch(function() {
if (times >= 8) {
clearInterval(timer)
self.loadGiftCoupons()
}
})
}, 1500)
},
loadGiftCoupons: function() {
var self = this
getJoinCouponRules().then(function(res) {
var rules = res.data || []
self.giftCoupons = rules.filter(function(r) {
return r.grantLevelCode === self.levelCode
})
}).catch(function() {})
},
loadFail: function() {
var self = this
if (!this.orderNo) {
this.failReason = '订单信息缺失'
return
}
// 尝试获取下一期支付参数(拆单未付清时可用)
nextOrder({ orderNo: this.orderNo }).then(function(res) {
var nd = res.data
if (nd && nd.done) {
self.failReason = '订单已全部支付,请稍后查看会员状态'
self.canContinue = false
} else if (nd && nd.orderInfo && nd.sign) {
self.canContinue = true
self.nextPayParams = nd
self.failReason = '第 ' + (nd.currentPeriod || 1) + ' 期尚未支付'
} else {
self.failReason = '支付未完成,请重试'
self.canContinue = false
}
}).catch(function() {
self.failReason = '支付未完成,请重试'
})
},
continuePay: function() {
var self = this
if (!this.nextPayParams) {
this.retryPay()
return
}
requestVirtualPayment(this.nextPayParams, {
success: function() {
uni.redirectTo({ url: '/pages/membership/result?orderNo=' + self.orderNo + '&status=success' })
},
fail: function(err) {
var errCode = err && err.errCode
if (errCode === -2) {
uni.showToast({ title: '支付已取消', icon: 'none' })
} else {
uni.showToast({ title: '支付未完成', icon: 'none' })
}
}
})
},
retryPay: function() {
uni.redirectTo({ url: '/pages/membership/upgrade' })
},
goBenefits: function() {
uni.redirectTo({ url: '/pages/membership/benefits' })
},
goHome: function() {
uni.switchTab({ url: '/pages/index/index' })
},
goUpgrade: function() {
uni.redirectTo({ url: '/pages/membership/upgrade' })
},
formatDate: function(date) {
if (!date) return ''
var d = parseDate(date)
if (!d) return ''
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
}
}
</script>
<style scoped>
.result-page {
min-height: 100vh;
background: #FFF7ED;
display: flex;
justify-content: center;
padding: 120rpx 30rpx 60rpx;
box-sizing: border-box;
}
.result-body {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.result-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.result-icon.success {
background: #D1FAE5;
}
.result-icon.fail {
background: #FEE2E2;
}
.icon-text {
font-size: 80rpx;
}
.result-title {
font-size: 40rpx;
font-weight: bold;
color: #1E293B;
display: block;
margin-bottom: 12rpx;
}
.result-sub {
font-size: 28rpx;
color: #64748B;
display: block;
margin-bottom: 48rpx;
}
.info-card {
width: 100%;
background: #FFFFFF;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 24rpx rgba(249, 115, 22, 0.08);
margin-bottom: 60rpx;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
}
.info-label {
font-size: 28rpx;
color: #64748B;
}
.info-value {
font-size: 28rpx;
color: #1E293B;
font-weight: 600;
}
.info-value.gift {
color: #F97316;
}
.btn-group {
width: 100%;
display: flex;
flex-direction: column;
}
.btn-primary {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: linear-gradient(135deg, #F97316 0%, #EA580C 100%);
color: #fff;
font-size: 32rpx;
font-weight: 600;
border-radius: 48rpx;
border: none;
margin-bottom: 24rpx;
}
.btn-primary::after {
border: none;
}
.btn-secondary {
width: 100%;
height: 96rpx;
line-height: 96rpx;
background: #FFFFFF;
color: #F97316;
font-size: 32rpx;
font-weight: 600;
border-radius: 48rpx;
border: 2rpx solid #FED7AA;
}
.btn-secondary::after {
border: none;
}
</style>
[ ] 步骤 2:语法校验
提取 script 块运行 node --check。
预期:无语法错误。
[ ] 步骤 3:Commit
git add cfc-frontend/pages/membership/result.vue
git commit -m "feat: 新增支付结果页 result.vue(成功态+赠券/失败态+续付)"
文件:
修改:cfc-frontend/pages.json(pages/membership 分包,第 669-691 行)
[ ] 步骤 1:在 upgrade 页面注册后追加两页
{
"root": "pages/membership",
"pages": [
{
"path": "index",
"style": {
"navigationBarTitleText": "会员中心"
}
},
{
"path": "benefits",
"style": {
"navigationBarTitleText": "我的权益"
}
},
{
"path": "upgrade",
"style": {
"navigationBarTitleText": "开通会员"
}
},
{
"path": "pay",
"style": {
"navigationBarTitleText": "确认订单"
}
},
{
"path": "result",
"style": {
"navigationBarTitleText": "支付结果"
}
}
]
},
[ ] 步骤 2:JSON 语法校验
运行:node -e "JSON.parse(require('fs').readFileSync('cfc-frontend/pages.json','utf8'));console.log('pages.json OK')"
预期:pages.json OK
[ ] 步骤 3:Commit
git add cfc-frontend/pages.json
git commit -m "feat: pages.json 注册会员 pay/result 页面"
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
依次对 upgrade.vue、pay.vue、result.vue 提取 script 块做 node --check;对 pages.json 做 JSON.parse。
预期:全部无错误。
对照 docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md 第 6 节改动清单逐项确认:
[x] pages.json 注册(任务 7)
[ ] 步骤 4:最终 Commit(如有遗漏改动)
git add -A cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java cfc-frontend/utils/api.js cfc-frontend/pages/membership/ cfc-frontend/pages.json
git status
git commit -m "feat: 会员支付升级页面上线(upgrade→pay→result三段式+原价+优惠券修复)"
注意:
git add -A <paths>只添加列出的路径,不会带入工作区其他未提交改动(如他人 Product 相关修改)。提交前必须git diff --cached --stat核对仅含目标文件。
1. 规格覆盖度:
2. 占位符扫描: 无「待定/TODO/后续实现」;所有步骤含完整代码;命令精确。
3. 类型一致性:
getOriginalPriceForPeriod 返回分(int),比较用 original > current * 100(current 为元字符串 → 注意:getPriceForPeriod 返回 (plan.yearly / 100).toFixed(0) 是元字符串!showOriginalPrice 中 current * 100 会把字符串隐式转数字再乘——正确。但更稳妥:直接用 plan 原始分值比较。实现时可改为读取 plan 原始字段,见下方备注)showOriginalPrice 中 current 来自 getPriceForPeriod()(元字符串),original > current * 100 数学上等价于「原价分 > 现价分」✅;getOriginalPriceForPeriod 返回分,模板中 getOriginalPriceForPeriod(p.key) / 100 显示元 ✅price 存分(从 levels[i].yearly 取,plans 接口返回分),priceText = (price/100).toFixed(2) ✅;originalPrice 存分,showOriginal 用 originalPrice > price 比较 ✅;couponDiscount 折扣券按现价比例、满减按 value(分)✅;finalPrice = price - couponDiscount(分)✅createOrder(levelCode, 'pay', period, couponId) 与任务 3 签名一致 ✅requestVirtualPayment(dto) 接收 VirtualPayParamsDTO(orderInfo/sign/signature/currentPeriod/totalPeriods)✅nextOrder({orderNo}) 返回 {done} 或新支付参数 ✅getJoinCouponRules() 返回 grantLevelCode/grantQuantity/name ✅(与 upgrade.vue 现有用法一致)uni.redirectTo 用于 result 返回,避免页面栈堆积 ✅4. 小程序限制合规: 无 ?.、无 CSS Grid(全 flex)、:key 用 coupon.id(纯属性,无表达式)✅、日期用 parseDate() ✅、Vue 2 Options API ✅。