purchase.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  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() {
  284. uni.showToast({ title: '支付未完成', icon: 'none' })
  285. }
  286. })
  287. } catch (e) {
  288. if (e.code === 46001) {
  289. uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
  290. } else {
  291. uni.showToast({ title: e.message || '支付失败', icon: 'none' })
  292. }
  293. } finally {
  294. this.submitting = false
  295. uni.hideLoading()
  296. }
  297. },
  298. pollOrderStatus: function(orderNo) {
  299. var self = this
  300. var times = 0
  301. var timer = setInterval(function() {
  302. times++
  303. getAssessmentOrderDetail(orderNo).then(function(res) {
  304. var order = res.data || {}
  305. if (order.status === 'paid') {
  306. clearInterval(timer)
  307. uni.showToast({ title: '支付成功', icon: 'success' })
  308. setTimeout(function() {
  309. uni.navigateTo({ url: '/pages/assessment/apply' })
  310. }, 800)
  311. } else if (times >= 5) {
  312. clearInterval(timer)
  313. uni.navigateTo({ url: '/pages/assessment/apply' })
  314. }
  315. }).catch(function() {
  316. if (times >= 5) { clearInterval(timer); uni.navigateTo({ url: '/pages/assessment/apply' }) }
  317. })
  318. }, 2000)
  319. }
  320. }
  321. }
  322. </script>
  323. <style scoped>
  324. .container {
  325. padding: 30rpx;
  326. background: #F8F8F8;
  327. min-height: 100vh;
  328. }
  329. .header {
  330. text-align: center;
  331. padding: 60rpx 0 40rpx;
  332. }
  333. .title {
  334. font-size: 48rpx;
  335. font-weight: bold;
  336. color: #333;
  337. display: block;
  338. margin-bottom: 20rpx;
  339. }
  340. .subtitle {
  341. font-size: 28rpx;
  342. color: #666;
  343. }
  344. .section {
  345. background: #fff;
  346. border-radius: 20rpx;
  347. padding: 30rpx;
  348. margin-bottom: 30rpx;
  349. }
  350. .section-title {
  351. font-size: 32rpx;
  352. font-weight: bold;
  353. color: #333;
  354. margin-bottom: 20rpx;
  355. }
  356. .guide-selector,
  357. .package-list {
  358. display: flex;
  359. flex-direction: column;
  360. gap: 20rpx;
  361. }
  362. .guide-item {
  363. position: relative;
  364. display: flex;
  365. align-items: center;
  366. background: #F8F8F8;
  367. border: 2rpx solid #E0E0E0;
  368. border-radius: 10rpx;
  369. padding: 20rpx;
  370. }
  371. .guide-item.selected {
  372. border-color: #667eea;
  373. background: #F0F4FF;
  374. }
  375. .guide-avatar {
  376. width: 60rpx;
  377. height: 60rpx;
  378. border-radius: 50%;
  379. object-fit: cover;
  380. margin-right: 20rpx;
  381. }
  382. .guide-info {
  383. flex: 1;
  384. }
  385. .guide-name {
  386. font-size: 28rpx;
  387. font-weight: bold;
  388. color: #333;
  389. display: block;
  390. margin-bottom: 5rpx;
  391. }
  392. .guide-price {
  393. font-size: 24rpx;
  394. color: #F97316;
  395. font-weight: bold;
  396. }
  397. .guide-check {
  398. width: 40rpx;
  399. height: 40rpx;
  400. background: #667eea;
  401. color: #fff;
  402. border-radius: 50%;
  403. display: flex;
  404. align-items: center;
  405. justify-content: center;
  406. font-size: 24rpx;
  407. }
  408. .package-item {
  409. position: relative;
  410. background: #F8F8F8;
  411. border: 2rpx solid #E0E0E0;
  412. border-radius: 10rpx;
  413. padding: 20rpx;
  414. }
  415. .package-item.selected {
  416. border-color: #667eea;
  417. background: #F0F4FF;
  418. }
  419. .package-header {
  420. display: flex;
  421. justify-content: space-between;
  422. align-items: center;
  423. margin-bottom: 15rpx;
  424. }
  425. .package-name {
  426. font-size: 30rpx;
  427. font-weight: bold;
  428. color: #333;
  429. }
  430. .package-price {
  431. text-align: right;
  432. }
  433. .price {
  434. font-size: 32rpx;
  435. font-weight: bold;
  436. color: #F97316;
  437. display: block;
  438. }
  439. .original-price {
  440. font-size: 24rpx;
  441. color: #999;
  442. text-decoration: line-through;
  443. margin-left: 10rpx;
  444. }
  445. .package-desc {
  446. font-size: 26rpx;
  447. color: #666;
  448. margin-bottom: 15rpx;
  449. }
  450. .package-features {
  451. display: flex;
  452. flex-wrap: wrap;
  453. gap: 10rpx;
  454. }
  455. .feature-item {
  456. display: flex;
  457. align-items: center;
  458. background: #E8F5E9;
  459. padding: 8rpx 12rpx;
  460. border-radius: 20rpx;
  461. }
  462. .feature-icon {
  463. color: #4CAF50;
  464. font-size: 24rpx;
  465. margin-right: 8rpx;
  466. }
  467. .feature-text {
  468. font-size: 24rpx;
  469. color: #333;
  470. }
  471. .package-check {
  472. position: absolute;
  473. top: 20rpx;
  474. right: 20rpx;
  475. width: 40rpx;
  476. height: 40rpx;
  477. background: #667eea;
  478. color: #fff;
  479. border-radius: 50%;
  480. display: flex;
  481. align-items: center;
  482. justify-content: center;
  483. font-size: 24rpx;
  484. }
  485. .price-breakdown {
  486. background: #F8F8F8;
  487. border-radius: 10rpx;
  488. padding: 20rpx;
  489. }
  490. .price-row {
  491. display: flex;
  492. justify-content: space-between;
  493. align-items: center;
  494. margin-bottom: 15rpx;
  495. }
  496. .price-row:last-child {
  497. margin-bottom: 0;
  498. }
  499. .price-label {
  500. font-size: 28rpx;
  501. color: #666;
  502. }
  503. .price-value {
  504. font-size: 32rpx;
  505. font-weight: bold;
  506. color: #333;
  507. }
  508. .price-value.discount {
  509. color: #F97316;
  510. }
  511. .price-row.total .price-label {
  512. font-size: 30rpx;
  513. font-weight: bold;
  514. color: #333;
  515. }
  516. .price-row.total .price-value {
  517. font-size: 36rpx;
  518. color: #F97316;
  519. }
  520. .btn-submit {
  521. width: 100%;
  522. height: 90rpx;
  523. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  524. color: #fff;
  525. border-radius: 45rpx;
  526. font-size: 32rpx;
  527. font-weight: bold;
  528. margin-top: 40rpx;
  529. }
  530. .btn-submit[disabled] {
  531. background: #CCCCCC;
  532. }
  533. </style>