purchase.vue 16 KB

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