|
@@ -0,0 +1,1326 @@
|
|
|
|
|
+# 会员支付/升级页面实现计划(方案 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", "高级家庭会员月费(分)");` 这一行之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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.java`(`getPricingPlans()` 方法,第 317-348 行)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:修改 getPricingPlans() 增加原价字段**
|
|
|
|
|
+
|
|
|
|
|
+将 `getPricingPlans()` 中三个价格分支各补充原价字段:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 付费等级价格从 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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 参数**
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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:改写模板 — 移除优惠券行,主按钮改「下一步」**
|
|
|
|
|
+
|
|
|
|
|
+模板中两处(开通卡片 + 续费卡片)的优惠券行删除,按钮改为:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <!-- 开通卡片 -->
|
|
|
|
|
+ <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 中 `createOrder`、`getCouponList`、`requestVirtualPayment`、`nextOrder`
|
|
|
|
|
+2. data 中删除 `coupons`、`selectedCoupon`、`showCouponPicker`、`joinRules`(joinRules 保留,开通赠券仍展示)
|
|
|
|
|
+3. 删除 methods:`openCouponPicker`、`selectCoupon`、`removeCoupon`、`getCouponCondition`、`handleUpgrade`、`doUpgrade`、`pollOrderStatus`、`payInstallments`、`handleRenew`、`doRenew`
|
|
|
|
|
+4. 新增 `goNext`:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ goNext: function() {
|
|
|
|
|
+ var levelCode = this.selectedLevel
|
|
|
|
|
+ var period = this.selectedPeriod
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/membership/pay?levelCode=' + levelCode + '&period=' + period })
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+5. 新增划线原价 computed:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ visiblePeriods() { /* 保留现有 */ },
|
|
|
|
|
+ availableCoupons() { /* 删除此 computed */ }
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `methods` 中新增原价查询方法(替代 `availableCoupons` 的位置):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ 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
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+6. **修复会员状态判断 bug**:`loadData()` 中:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ if (this.membership && this.membership.levelCode && this.membership.levelCode !== 'FREE') {
|
|
|
|
|
+```
|
|
|
|
|
+改为:
|
|
|
|
|
+```js
|
|
|
|
|
+ if (this.membership && this.membership.memberLevel && this.membership.memberLevel !== 'FREE') {
|
|
|
|
|
+```
|
|
|
|
|
+并同步下方 `this.selectedLevel = this.membership.levelCode` → `this.membership.memberLevel`,`this.currentLevelName` 取值逻辑不变(`m.memberLevel` 时用 levelName 映射或保留 '家庭会员')。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:改写样式 — 划线原价样式**
|
|
|
|
|
+
|
|
|
|
|
+在样式区追加:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+/* ===== 划线原价 ===== */
|
|
|
|
|
+.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` 之后)加:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <text class="period-original" v-if="showOriginalPrice(p.key)">¥{{ getOriginalPriceForPeriod(p.key) / 100 }}</text>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+升级价格大数字旁(`price-unit` 之前)加:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <text class="price-original-line" v-if="showOriginalPrice(selectedPeriod)">¥{{ getOriginalPriceForPeriod(selectedPeriod) / 100 }}</text>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:语法校验**
|
|
|
|
|
+
|
|
|
|
|
+提取 script 块运行 `node --check`(用 node 脚本提取 `<script>` 与 `</script>` 之间的内容,写临时文件后 `node --check`)。
|
|
|
|
|
+预期:无语法错误。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 完整文件**
|
|
|
|
|
+
|
|
|
|
|
+```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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 完整文件**
|
|
|
|
|
+
|
|
|
|
|
+```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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-frontend/pages/membership/result.vue
|
|
|
|
|
+git commit -m "feat: 新增支付结果页 result.vue(成功态+赠券/失败态+续付)"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 7:pages.json 注册 pay、result 页面
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-frontend/pages.json`(`pages/membership` 分包,第 669-691 行)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 upgrade 页面注册后追加两页**
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+ {
|
|
|
|
|
+ "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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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.vue`、`pay.vue`、`result.vue` 提取 script 块做 `node --check`;对 `pages.json` 做 JSON.parse。
|
|
|
|
|
+预期:全部无错误。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:自查设计文档覆盖度**
|
|
|
|
|
+
|
|
|
|
|
+对照 `docs/superpowers/specs/2026-08-09-membership-pay-upgrade-design.md` 第 6 节改动清单逐项确认:
|
|
|
|
|
+- [x] DatabaseInitializer 原价键(任务 1)
|
|
|
|
|
+- [x] MembershipController plans 原价字段(任务 2)
|
|
|
|
|
+- [x] api.js createOrder couponId(任务 3)
|
|
|
|
|
+- [x] upgrade.vue 改造(任务 4)
|
|
|
|
|
+- [x] pay.vue 新增(任务 5)
|
|
|
|
|
+- [x] result.vue 新增(任务 6)
|
|
|
|
|
+- [x] pages.json 注册(任务 7)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:最终 Commit(如有遗漏改动)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 的 `showOriginalPrice` 中 `current` 来自 `getPriceForPeriod()`(元字符串),`original > current * 100` 数学上等价于「原价分 > 现价分」✅;`getOriginalPriceForPeriod` 返回分,模板中 `getOriginalPriceForPeriod(p.key) / 100` 显示元 ✅
|
|
|
|
|
+- pay.vue:`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}` 或新支付参数 ✅
|
|
|
|
|
+- result.vue `getJoinCouponRules()` 返回 `grantLevelCode/grantQuantity/name` ✅(与 upgrade.vue 现有用法一致)
|
|
|
|
|
+- `uni.redirectTo` 用于 result 返回,避免页面栈堆积 ✅
|
|
|
|
|
+
|
|
|
|
|
+**4. 小程序限制合规:** 无 `?.`、无 CSS Grid(全 flex)、`:key` 用 `coupon.id`(纯属性,无表达式)✅、日期用 `parseDate()` ✅、Vue 2 Options API ✅。
|