2026-08-09-membership-pay-upgrade.md 42 KB

会员支付/升级页面实现计划(方案 B)

面向 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 两页

任务 1:后端 sys_config 新增原价键

文件:

  • 修改: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 空格)。

  • 步骤 2:验证编译

运行: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 年费月费原价)"
    

任务 2:后端 /api/membership/plans 增加原价字段

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.javagetPricingPlans() 方法,第 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);
            }
  • 步骤 2:验证编译

运行: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)"
    

任务 3:前端 api.js createOrder 增加 couponId 透传

文件:

  • 修改: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 可选)

  • 步骤 2:语法校验

运行: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(会员优惠券此前未生效)"
    

任务 4:改造 upgrade.vue(套餐选择页)

文件:

  • 修改:cfc-frontend/pages/membership/upgrade.vue(整文件)

要点: 移除优惠券选择 + 支付调用 + 支付结果轮询;底部主按钮改「下一步」跳转 pay;修复会员状态判断 bug(m.memberLevel 而非 m.levelCode);价格区展示划线原价。

  • 步骤 1:改写模板 — 移除优惠券行,主按钮改「下一步」

模板中两处(开通卡片 + 续费卡片)的优惠券行删除,按钮改为:

        <!-- 开通卡片 -->
        <button class="btn-upgrade" @click="goNext">下一步</button>
        ...
        <!-- 续费卡片 -->
        <button class="btn-upgrade btn-renew" @click="goNext">下一步</button>

删除整个 coupon-row 块(两处)和底部 modal-mask(Coupon Picker Bottom Sheet)整块。

  • 步骤 2:改写 script — 移除支付/优惠券逻辑,加 goNext 与划线原价

script 变更:

  1. 删除 import 中 createOrdergetCouponListrequestVirtualPaymentnextOrder
  2. data 中删除 couponsselectedCouponshowCouponPickerjoinRules(joinRules 保留,开通赠券仍展示)
  3. 删除 methods:openCouponPickerselectCouponremoveCoupongetCouponConditionhandleUpgradedoUpgradepollOrderStatuspayInstallmentshandleRenewdoRenew
  4. 新增 goNext

    goNext: function() {
      var levelCode = this.selectedLevel
      var period = this.selectedPeriod
      uni.navigateTo({ url: '/pages/membership/pay?levelCode=' + levelCode + '&period=' + period })
    },
    
  5. 新增划线原价 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
    },
  1. 修复会员状态判断 bugloadData() 中:

        if (this.membership && this.membership.levelCode && this.membership.levelCode !== 'FREE') {
    

改为:

        if (this.membership && this.membership.memberLevel && this.membership.memberLevel !== 'FREE') {

并同步下方 this.selectedLevel = this.membership.levelCodethis.membership.memberLevelthis.currentLevelName 取值逻辑不变(m.memberLevel 时用 levelName 映射或保留 '家庭会员')。

  • 步骤 3:改写样式 — 划线原价样式

在样式区追加:

/* ===== 划线原价 ===== */
.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;
}
  • 步骤 4:模板价格区加划线原价

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>
  • 步骤 5:语法校验

提取 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,支持划线原价"
    

任务 5:新增 pay.vue(订单确认页)

文件:

  • 创建: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(明细/优惠券/拆单提示/虚拟支付逐期)"
    

任务 6:新增 result.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(成功态+赠券/失败态+续付)"
    

任务 7:pages.json 注册 pay、result 页面

文件:

  • 修改:cfc-frontend/pages.jsonpages/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 页面"
    

任务 8:整体验证与收尾

  • 步骤 1:后端编译验证

运行:cd cfc-backend && mvn clean compile 预期:BUILD SUCCESS

  • 步骤 2:前端语法校验(三个改动页面 + api.js + pages.json)

依次对 upgrade.vuepay.vueresult.vue 提取 script 块做 node --check;对 pages.json 做 JSON.parse。 预期:全部无错误。

  • 步骤 3:自查设计文档覆盖度

对照 docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md 第 6 节改动清单逐项确认:

  • DatabaseInitializer 原价键(任务 1)
  • MembershipController plans 原价字段(任务 2)
  • api.js createOrder couponId(任务 3)
  • upgrade.vue 改造(任务 4)
  • pay.vue 新增(任务 5)
  • result.vue 新增(任务 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. 规格覆盖度:

  • 设计第 3.1 sys_config 原价键 → 任务 1 ✅
  • 设计第 3.2 plans 接口原价字段 → 任务 2 ✅
  • 设计第 4.1 upgrade 改造(移除支付/优惠券、下一步、修 bug、划线原价)→ 任务 4 ✅
  • 设计第 4.2 pay.vue(明细/优惠券 Picker/拆单提示/支付调用)→ 任务 5 ✅
  • 设计第 4.3 result.vue(成功+赠券/失败+续付)→ 任务 6 ✅
  • 设计第 4.4 划线原价展示规则 → 任务 4 步骤 4 + 任务 5 步骤 1(showOriginal)✅
  • 设计第 6 改动清单 api.js couponId → 任务 3 ✅
  • pages.json 注册 → 任务 7 ✅

2. 占位符扫描: 无「待定/TODO/后续实现」;所有步骤含完整代码;命令精确。

3. 类型一致性:

  • getOriginalPriceForPeriod 返回分(int),比较用 original > current * 100(current 为元字符串 → 注意:getPriceForPeriod 返回 (plan.yearly / 100).toFixed(0)元字符串!showOriginalPrice 中 current * 100 会把字符串隐式转数字再乘——正确。但更稳妥:直接用 plan 原始分值比较。实现时可改为读取 plan 原始字段,见下方备注)
  • 备注:任务 4 步骤 2 的 showOriginalPricecurrent 来自 getPriceForPeriod()(元字符串),original > current * 100 数学上等价于「原价分 > 现价分」✅;getOriginalPriceForPeriod 返回分,模板中 getOriginalPriceForPeriod(p.key) / 100 显示元 ✅
  • pay.vue:price 存分(从 levels[i].yearly 取,plans 接口返回分),priceText = (price/100).toFixed(2) ✅;originalPrice 存分,showOriginaloriginalPrice > price 比较 ✅;couponDiscount 折扣券按现价比例、满减按 value(分)✅;finalPrice = price - couponDiscount(分)✅
  • createOrder(levelCode, 'pay', period, couponId) 与任务 3 签名一致 ✅
  • requestVirtualPayment(dto) 接收 VirtualPayParamsDTO(orderInfo/sign/signature/currentPeriod/totalPeriods)✅
  • nextOrder({orderNo}) 返回 {done} 或新支付参数 ✅
  • result.vue getJoinCouponRules() 返回 grantLevelCode/grantQuantity/name ✅(与 upgrade.vue 现有用法一致)
  • uni.redirectTo 用于 result 返回,避免页面栈堆积 ✅

4. 小程序限制合规:?.、无 CSS Grid(全 flex)、:keycoupon.id(纯属性,无表达式)✅、日期用 parseDate() ✅、Vue 2 Options API ✅。