purchase.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <template>
  2. <view class="container">
  3. <view class="header">
  4. <text class="title">选择测评任务模板</text>
  5. <text class="subtitle">选择适合孩子的测评任务模板</text>
  6. </view>
  7. <!-- 家庭数据区 -->
  8. <!-- 成长规划师选择 -->
  9. <view class="section" v-if="!guideId">
  10. <view class="section-title">选择成长规划师</view>
  11. <view class="guide-selector">
  12. <view
  13. class="guide-item"
  14. :class="{ 'selected': selectedGuideId === guide.guideId }"
  15. v-for="guide in availableGuides"
  16. :key="guide.guideId"
  17. @click="selectGuide(guide)"
  18. >
  19. <image class="guide-avatar" :src="guide.guideAvatar || '/static/default-avatar.png'" />
  20. <view class="guide-info">
  21. <text class="guide-name">{{ guide.guideName }}</text>
  22. <text class="guide-price">{{ formatPriceWithSymbol(guide.price) }}</text>
  23. </view>
  24. <view class="guide-check" v-if="selectedGuideId === guide.guideId">✓</view>
  25. </view>
  26. </view>
  27. </view>
  28. <!-- 任务模板列表 -->
  29. <view class="section">
  30. <view class="section-title">测评任务模板</view>
  31. <view class="package-list">
  32. <view
  33. class="package-item"
  34. :class="{ 'selected': selectedPackageId === pkg.id }"
  35. v-for="pkg in packages"
  36. :key="pkg.id"
  37. @click="selectPackage(pkg)"
  38. >
  39. <view class="package-header">
  40. <text class="package-name">{{ pkg.name }}</text>
  41. <view class="package-price">
  42. <text class="price">{{ formatPriceWithSymbol(pkg.price) }}</text>
  43. <text class="original-price" v-if="pkg.originalPrice">{{ formatPriceWithSymbol(pkg.originalPrice) }}</text>
  44. </view>
  45. </view>
  46. <view class="package-desc">{{ pkg.description }}</view>
  47. <view class="package-features">
  48. <view class="feature-item" v-for="(feature, index) in pkg.features" :key="index">
  49. <text class="feature-icon">✓</text>
  50. <text class="feature-text">{{ feature }}</text>
  51. </view>
  52. </view>
  53. <view class="package-check" v-if="selectedPackageId === pkg.id">✓</view>
  54. </view>
  55. </view>
  56. </view>
  57. <!-- 价格信息 -->
  58. <view class="section" v-if="selectedPackageId">
  59. <view class="section-title">费用明细</view>
  60. <view class="price-breakdown">
  61. <view class="price-row">
  62. <text class="price-label">任务模板价格</text>
  63. <text class="price-value">{{ formatPriceWithSymbol(selectedPackage.price) }}</text>
  64. </view>
  65. <view class="price-row" v-if="selectedGuideId">
  66. <text class="price-label">成长规划师费用</text>
  67. <text class="price-value">{{ formatPriceWithSymbol(guidePrice) }}</text>
  68. </view>
  69. <view class="price-row" v-if="discountAmount > 0">
  70. <text class="price-label">邀请码优惠</text>
  71. <text class="price-value discount">-{{ formatPriceWithSymbol(discountAmount) }}</text>
  72. </view>
  73. <view class="price-row total">
  74. <text class="price-label">总计</text>
  75. <text class="price-value">{{ formatPriceWithSymbol(totalPrice) }}</text>
  76. </view>
  77. </view>
  78. </view>
  79. <!-- 提交按钮 -->
  80. <button class="btn-submit" @click="submit" :disabled="!selectedPackageId || submitting" :loading="submitting">确认购买</button>
  81. </view>
  82. </template>
  83. <script>
  84. import { getAvailableGuides, getPackagePrice, createAssessmentOrder, createVirtualPayOrder, requestVirtualPayment, getAssessmentOrderDetail } from '../../utils/api.js'
  85. export default {
  86. data() {
  87. return {
  88. guideId: null,
  89. selectedGuideId: null,
  90. selectedGuideName: '',
  91. selectedPackageId: null,
  92. selectedPackage: null,
  93. availableGuides: [],
  94. packages: [],
  95. totalPrice: 0,
  96. guidePrice: 0,
  97. discountAmount: 0,
  98. inviteCode: '',
  99. inviteCodeInfo: null,
  100. submitting: false
  101. }
  102. },
  103. computed: {},
  104. onLoad(options) {
  105. // 如果有邀请码参数,自动填充
  106. if (options.inviteCode) {
  107. this.inviteCode = options.inviteCode
  108. this.validateInviteCode()
  109. }
  110. // 如果有成长规划师ID参数,自动选择
  111. if (options.guideId) {
  112. this.guideId = options.guideId
  113. }
  114. this.loadPackages()
  115. this.loadAvailableGuides()
  116. },
  117. methods: {
  118. async loadPackages() {
  119. try {
  120. // TODO: 从后端获取任务模板列表
  121. // 这里简化处理,使用模拟数据
  122. this.packages = [
  123. {
  124. id: 1,
  125. name: '基础测评任务模板',
  126. description: '包含注意力、专注力、记忆力三项基础测评',
  127. price: 299,
  128. originalPrice: 399,
  129. features: ['注意力测评', '专注力测评', '记忆力测评', '测评报告', '成长建议']
  130. },
  131. {
  132. id: 2,
  133. name: '全面测评任务模板',
  134. description: '包含五项全面测评,提供详细分析报告',
  135. price: 499,
  136. originalPrice: 699,
  137. features: ['注意力测评', '专注力测评', '记忆力测评', '逻辑思维测评', '综合分析报告', '个性化成长建议', '后续跟踪']
  138. },
  139. {
  140. id: 3,
  141. name: '高级测评任务模板',
  142. name: '高级测评任务模板',
  143. description: '包含所有测评项目,提供专家一对一解读',
  144. price: 799,
  145. originalPrice: 999,
  146. features: ['所有测评项目', '专家一对一解读', '详细分析报告', '个性化成长方案', '后续跟踪服务', '家长指导建议']
  147. }
  148. ]
  149. } catch (e) {
  150. console.error('获取任务模板列表失败', e)
  151. }
  152. },
  153. async loadAvailableGuides() {
  154. try {
  155. const packageId = this.selectedPackageId ? this.selectedPackageId : 1
  156. const res = await getAvailableGuides(packageId)
  157. if (res.code === 200) {
  158. this.availableGuides = res.data || []
  159. // 如果有邀请码,自动选择对应的成长规划师
  160. if (this.inviteCodeInfo && this.inviteCodeInfo.guideId) {
  161. const guide = this.availableGuides.find(g => g.guideId === this.inviteCodeInfo.guideId)
  162. if (guide) {
  163. this.selectGuide(guide)
  164. }
  165. } else if (this.guideId) {
  166. // 如果有成长规划师ID参数,自动选择
  167. const guide = this.availableGuides.find(g => g.guideId === this.guideId)
  168. if (guide) {
  169. this.selectGuide(guide)
  170. }
  171. }
  172. }
  173. } catch (e) {
  174. console.error('获取成长规划师列表失败', e)
  175. }
  176. },
  177. selectGuide(guide) {
  178. this.selectedGuideId = guide.guideId
  179. this.selectedGuideName = guide.guideName
  180. this.guidePrice = guide.price
  181. this.calculatePrice()
  182. },
  183. selectPackage(pkg) {
  184. this.selectedPackageId = pkg.id
  185. this.selectedPackage = pkg
  186. this.calculatePrice()
  187. },
  188. calculatePrice() {
  189. if (!this.selectedPackage) {
  190. this.totalPrice = 0
  191. return
  192. }
  193. this.totalPrice = this.selectedPackage.price
  194. this.guidePrice = 0
  195. this.discountAmount = 0
  196. if (this.selectedGuideId) {
  197. const guide = this.availableGuides.find(g => g.guideId === this.selectedGuideId)
  198. if (guide) {
  199. this.guidePrice = guide.price
  200. this.totalPrice += guide.price
  201. }
  202. }
  203. if (this.inviteCodeInfo && this.inviteCodeInfo.discountAmount) {
  204. this.discountAmount = this.inviteCodeInfo.discountAmount
  205. this.totalPrice -= this.discountAmount
  206. }
  207. },
  208. async validateInviteCode() {
  209. if (!this.inviteCode) {
  210. return
  211. }
  212. try {
  213. const res = await validateInviteCode(this.inviteCode)
  214. if (res.code === 200) {
  215. this.inviteCodeInfo = res.data
  216. // 自动选择邀请码关联的成长规划师
  217. if (this.inviteCodeInfo.guideId) {
  218. const guide = this.availableGuides.find(g => g.guideId === this.inviteCodeInfo.guideId)
  219. if (guide) {
  220. this.selectGuide(guide)
  221. }
  222. }
  223. }
  224. } catch (e) {
  225. console.error('验证邀请码失败', e)
  226. }
  227. },
  228. async submit() {
  229. if (this.submitting) return
  230. if (!this.selectedPackageId) {
  231. uni.showToast({ title: '请选择任务模板', icon: 'none' })
  232. return
  233. }
  234. if (!this.selectedGuideId) {
  235. uni.showToast({ title: '请选择成长规划师', icon: 'none' })
  236. return
  237. }
  238. var memberId = uni.getStorageSync('currentChildId')
  239. if (!memberId) {
  240. uni.showToast({ title: '请先选择孩子', icon: 'none' })
  241. return
  242. }
  243. this.submitting = true
  244. uni.showLoading({ title: '创建订单中...' })
  245. var self = this
  246. try {
  247. var createRes = await createAssessmentOrder({
  248. memberId: memberId,
  249. guideId: this.selectedGuideId,
  250. guideName: this.selectedGuideName,
  251. packageId: this.selectedPackageId,
  252. packageName: this.selectedPackage ? this.selectedPackage.name : '',
  253. totalPrice: this.totalPrice,
  254. discountAmount: this.discountAmount
  255. })
  256. if (createRes.code !== 200 || !createRes.data || !createRes.data.orderNo) {
  257. uni.showToast({ title: '创建订单失败', icon: 'none' })
  258. return
  259. }
  260. var orderNo = createRes.data.orderNo
  261. // 支付(内联返回 VirtualPayParamsDTO;testMode 返回 null 表示 mock 直接支付成功)
  262. var payRes = await createVirtualPayOrder(orderNo, 'ASSESSMENT')
  263. if (payRes.code !== 200) {
  264. uni.showToast({ title: payRes.message || '支付失败', icon: 'none' })
  265. return
  266. }
  267. if (!payRes.data) {
  268. // testMode:mock 已直接标记支付成功 → 跳预约页
  269. uni.showToast({ title: '支付成功', icon: 'success' })
  270. setTimeout(function() {
  271. uni.navigateTo({ url: '/pages/assessment/apply' })
  272. }, 800)
  273. return
  274. }
  275. // 真实模式:拉起虚拟支付
  276. requestVirtualPayment(payRes.data, {
  277. success: function() {
  278. uni.showToast({ title: '支付成功,确认中…', icon: 'none' })
  279. self.pollOrderStatus(orderNo)
  280. },
  281. fail: function(err) {
  282. console.error('[测评支付] requestVirtualPayment 失败', err)
  283. var errCode = err && err.errCode
  284. var msg = ''
  285. if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录后支付'
  286. else if (errCode === -15010) msg = '商品未发布,请联系客服'
  287. else if (errCode === -15013) msg = '商品价格异常'
  288. else if (errCode === -2) msg = '支付已取消'
  289. else if (err && err.errMsg) msg = err.errMsg
  290. uni.showToast({ title: msg || '支付未完成', icon: 'none' })
  291. }
  292. })
  293. } catch (e) {
  294. if (e.code === 46001) {
  295. uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
  296. } else {
  297. uni.showToast({ title: e.message || '支付失败', icon: 'none' })
  298. }
  299. } finally {
  300. this.submitting = false
  301. uni.hideLoading()
  302. }
  303. },
  304. pollOrderStatus: function(orderNo) {
  305. var self = this
  306. var times = 0
  307. var timer = setInterval(function() {
  308. times++
  309. getAssessmentOrderDetail(orderNo).then(function(res) {
  310. var order = res.data || {}
  311. if (order.status === 'paid') {
  312. clearInterval(timer)
  313. uni.showToast({ title: '支付成功', icon: 'success' })
  314. setTimeout(function() {
  315. uni.navigateTo({ url: '/pages/assessment/apply' })
  316. }, 800)
  317. } else if (times >= 5) {
  318. clearInterval(timer)
  319. uni.navigateTo({ url: '/pages/assessment/apply' })
  320. }
  321. }).catch(function() {
  322. if (times >= 5) { clearInterval(timer); uni.navigateTo({ url: '/pages/assessment/apply' }) }
  323. })
  324. }, 2000)
  325. }
  326. }
  327. }
  328. </script>
  329. <style scoped>
  330. .container {
  331. padding: 30rpx;
  332. background: #F8F8F8;
  333. min-height: 100vh;
  334. }
  335. .header {
  336. text-align: center;
  337. padding: 60rpx 0 40rpx;
  338. }
  339. .title {
  340. font-size: 48rpx;
  341. font-weight: bold;
  342. color: #333;
  343. display: block;
  344. margin-bottom: 20rpx;
  345. }
  346. .subtitle {
  347. font-size: 28rpx;
  348. color: #666;
  349. }
  350. .section {
  351. background: #fff;
  352. border-radius: 20rpx;
  353. padding: 30rpx;
  354. margin-bottom: 30rpx;
  355. }
  356. .section-title {
  357. font-size: 32rpx;
  358. font-weight: bold;
  359. color: #333;
  360. margin-bottom: 20rpx;
  361. }
  362. .guide-selector,
  363. .package-list {
  364. display: flex;
  365. flex-direction: column;
  366. gap: 20rpx;
  367. }
  368. .guide-item {
  369. position: relative;
  370. display: flex;
  371. align-items: center;
  372. background: #F8F8F8;
  373. border: 2rpx solid #E0E0E0;
  374. border-radius: 10rpx;
  375. padding: 20rpx;
  376. }
  377. .guide-item.selected {
  378. border-color: #667eea;
  379. background: #F0F4FF;
  380. }
  381. .guide-avatar {
  382. width: 60rpx;
  383. height: 60rpx;
  384. border-radius: 50%;
  385. object-fit: cover;
  386. margin-right: 20rpx;
  387. }
  388. .guide-info {
  389. flex: 1;
  390. }
  391. .guide-name {
  392. font-size: 28rpx;
  393. font-weight: bold;
  394. color: #333;
  395. display: block;
  396. margin-bottom: 5rpx;
  397. }
  398. .guide-price {
  399. font-size: 24rpx;
  400. color: #F97316;
  401. font-weight: bold;
  402. }
  403. .guide-check {
  404. width: 40rpx;
  405. height: 40rpx;
  406. background: #667eea;
  407. color: #fff;
  408. border-radius: 50%;
  409. display: flex;
  410. align-items: center;
  411. justify-content: center;
  412. font-size: 24rpx;
  413. }
  414. .package-item {
  415. position: relative;
  416. background: #F8F8F8;
  417. border: 2rpx solid #E0E0E0;
  418. border-radius: 10rpx;
  419. padding: 20rpx;
  420. }
  421. .package-item.selected {
  422. border-color: #667eea;
  423. background: #F0F4FF;
  424. }
  425. .package-header {
  426. display: flex;
  427. justify-content: space-between;
  428. align-items: center;
  429. margin-bottom: 15rpx;
  430. }
  431. .package-name {
  432. font-size: 30rpx;
  433. font-weight: bold;
  434. color: #333;
  435. }
  436. .package-price {
  437. text-align: right;
  438. }
  439. .price {
  440. font-size: 32rpx;
  441. font-weight: bold;
  442. color: #F97316;
  443. display: block;
  444. }
  445. .original-price {
  446. font-size: 24rpx;
  447. color: #999;
  448. text-decoration: line-through;
  449. margin-left: 10rpx;
  450. }
  451. .package-desc {
  452. font-size: 26rpx;
  453. color: #666;
  454. margin-bottom: 15rpx;
  455. }
  456. .package-features {
  457. display: flex;
  458. flex-wrap: wrap;
  459. gap: 10rpx;
  460. }
  461. .feature-item {
  462. display: flex;
  463. align-items: center;
  464. background: #E8F5E9;
  465. padding: 8rpx 12rpx;
  466. border-radius: 20rpx;
  467. }
  468. .feature-icon {
  469. color: #4CAF50;
  470. font-size: 24rpx;
  471. margin-right: 8rpx;
  472. }
  473. .feature-text {
  474. font-size: 24rpx;
  475. color: #333;
  476. }
  477. .package-check {
  478. position: absolute;
  479. top: 20rpx;
  480. right: 20rpx;
  481. width: 40rpx;
  482. height: 40rpx;
  483. background: #667eea;
  484. color: #fff;
  485. border-radius: 50%;
  486. display: flex;
  487. align-items: center;
  488. justify-content: center;
  489. font-size: 24rpx;
  490. }
  491. .price-breakdown {
  492. background: #F8F8F8;
  493. border-radius: 10rpx;
  494. padding: 20rpx;
  495. }
  496. .price-row {
  497. display: flex;
  498. justify-content: space-between;
  499. align-items: center;
  500. margin-bottom: 15rpx;
  501. }
  502. .price-row:last-child {
  503. margin-bottom: 0;
  504. }
  505. .price-label {
  506. font-size: 28rpx;
  507. color: #666;
  508. }
  509. .price-value {
  510. font-size: 32rpx;
  511. font-weight: bold;
  512. color: #333;
  513. }
  514. .price-value.discount {
  515. color: #F97316;
  516. }
  517. .price-row.total .price-label {
  518. font-size: 30rpx;
  519. font-weight: bold;
  520. color: #333;
  521. }
  522. .price-row.total .price-value {
  523. font-size: 36rpx;
  524. color: #F97316;
  525. }
  526. .btn-submit {
  527. width: 100%;
  528. height: 90rpx;
  529. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  530. color: #fff;
  531. border-radius: 45rpx;
  532. font-size: 32rpx;
  533. font-weight: bold;
  534. margin-top: 40rpx;
  535. }
  536. .btn-submit[disabled] {
  537. background: #CCCCCC;
  538. }
  539. </style>