purchase.vue 16 KB

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