recharge.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. <template>
  2. <view class="subscription-page">
  3. <view v-if="servicePackages.length > 0" class="card-list">
  4. <view
  5. v-for="pkg in servicePackages"
  6. :key="pkg.grfwbid"
  7. class="service-card"
  8. :class="{ expanded: isExpanded(pkg) }"
  9. @click="toggleExpand(pkg)"
  10. >
  11. <view class="card-title">{{ pkg.mc }}</view>
  12. <view class="card-price-row">
  13. <view class="card-price">¥ {{ getPackageTotalPrice(pkg) }}</view>
  14. <button class="card-action" @click.stop="toggleExpand(pkg)">
  15. {{ isExpanded(pkg) ? '收起' : '订阅' }}
  16. </button>
  17. </view>
  18. <template v-if="!isExpanded(pkg)">
  19. <view class="card-preview">
  20. <rich-text :nodes="getPackageIntroHtml(pkg)"></rich-text>
  21. </view>
  22. </template>
  23. <template v-else>
  24. <view class="expanded-body" @click.stop>
  25. <view class="expanded-intro">
  26. <rich-text :nodes="getRichNodes(getPackageIntroHtml(pkg))"></rich-text>
  27. </view>
  28. <view class="detail-section-title">详情</view>
  29. <view v-for="(detail, idx) in getPackageDetailList(pkg)" :key="idx" class="detail-item">
  30. <view class="detail-item-title">{{ idx + 1 }}. {{ detail.title }}</view>
  31. <view class="detail-item-rt">
  32. <rich-text :nodes="getRichNodes(detail.html)"></rich-text>
  33. </view>
  34. </view>
  35. </view>
  36. <view class="expanded-footer" @click.stop>
  37. <view class="bottom-terms">
  38. <view class="checkbox" :class="{ checked: termsAccepted }" @click="toggleTermsAccepted">
  39. <text v-if="termsAccepted" class="checkmark">✓</text>
  40. </view>
  41. <text class="terms-text" @click="openTermsDrawer">《服务条款》</text>
  42. </view>
  43. <button
  44. class="bottom-btn"
  45. :disabled="isPaymentInProgress || !termsAccepted || !finalAmount || finalAmount <= 0"
  46. @click="handlePay"
  47. >
  48. 订阅
  49. </button>
  50. </view>
  51. </template>
  52. </view>
  53. </view>
  54. <view v-else class="empty-state">
  55. <text>暂无可用服务包</text>
  56. </view>
  57. <u-popup :show="termsDrawerVisible" mode="bottom" round="16" @close="closeTermsDrawer">
  58. <view class="terms-drawer">
  59. <view class="drawer-head">
  60. <view class="drawer-title">服务条款</view>
  61. </view>
  62. <scroll-view class="drawer-body" scroll-y>
  63. <view class="drawer-text">
  64. <view class="drawer-p">1. 订阅服务为虚拟服务,一经开通即刻生效。</view>
  65. <view class="drawer-p">2. 订阅权益以页面展示及系统实际开通为准。</view>
  66. <view class="drawer-p">3. 若出现网络/系统异常导致开通失败,请联系客服处理。</view>
  67. <view class="drawer-p">4. 更多条款内容以平台最终版本为准。</view>
  68. </view>
  69. </scroll-view>
  70. <view class="drawer-actions">
  71. <button class="drawer-agree" @click="agreeTerms">我已阅读并同意</button>
  72. </view>
  73. </view>
  74. </u-popup>
  75. </view>
  76. </template>
  77. <script setup>
  78. import { ref, computed } from 'vue'
  79. import { onLoad } from '@dcloudio/uni-app'
  80. import { grfwApi } from '@/api/grfw'
  81. // 兼容不同字段命名的支付参数
  82. const normalizePayParams = (res) => {
  83. const data = res?.data.ssData || res || {}
  84. const payload = data.prepay || data.payInfo || data
  85. if (!payload) {
  86. return {}
  87. }
  88. const rawPackage =
  89. payload.package ||
  90. payload.packageVal ||
  91. (payload.prepayId ? `prepay_id=${payload.prepayId}` : undefined)
  92. const normalizedPackage =
  93. typeof rawPackage === 'string' && rawPackage && !rawPackage.startsWith('prepay_id=')
  94. ? `prepay_id=${rawPackage}`
  95. : rawPackage
  96. return {
  97. appId: payload.appId,
  98. timeStamp: payload.timeStamp || payload.timestamp,
  99. nonceStr: payload.nonceStr || payload.noncestr,
  100. package: normalizedPackage,
  101. signType: payload.signType || payload.sign_type || 'RSA',
  102. paySign: payload.paySign || payload.sign,
  103. orderId: data.outTradeNo
  104. }
  105. }
  106. // 个人服务包数据
  107. const servicePackages = ref([]) // 服务包列表
  108. const selectedPackage = ref(null) // 选中的服务包
  109. const isPaymentInProgress = ref(false) // 支付进行中标志
  110. const expandedPackageId = ref('')
  111. const termsAccepted = ref(false)
  112. const termsDrawerVisible = ref(false)
  113. const buildIntroHtml = (rawText) => {
  114. if (!rawText || typeof rawText !== 'string') return ''
  115. const lines = rawText
  116. .split(/\r?\n/)
  117. .map((line) => line.trim())
  118. .filter(Boolean)
  119. if (!lines.length) return ''
  120. return lines
  121. .map((line) => `<p>${line}</p>`)
  122. .join('')
  123. }
  124. const normalizeServicePackages = (ssData) => {
  125. if (!Array.isArray(ssData)) return []
  126. // ssData[0] 按需求跳过,从 ssData[1] 开始读取服务包
  127. return ssData
  128. .slice(1)
  129. .filter((pkg) => pkg && pkg.grfwbid)
  130. .map((pkg) => {
  131. const detailItems = Array.isArray(pkg.grfwbmxList)
  132. ? [...pkg.grfwbmxList].sort((a, b) => (a?.xh || 0) - (b?.xh || 0))
  133. : []
  134. return {
  135. ...pkg,
  136. jg: parseFloat(pkg.jg) || 0,
  137. num: Number(pkg.num) || 0,
  138. introHtml: buildIntroHtml(pkg.ms),
  139. detailList: detailItems.map((item) => ({
  140. title: item?.mc || '',
  141. html: `<p>${item?.ms || getServiceDesc(item)}</p>`
  142. }))
  143. }
  144. })
  145. }
  146. // 最终支付金额(使用服务包价格,周包需要乘以周数)
  147. const finalAmount = computed(() => {
  148. if (selectedPackage.value) {
  149. const pkg = selectedPackage.value
  150. // 如果是周包(num > 0),总价 = 单价 × 周数
  151. if (pkg.num > 0) {
  152. return parseFloat(pkg.jg * pkg.num) || 0
  153. }
  154. return parseFloat(pkg.jg) || 0
  155. }
  156. return 0
  157. })
  158. // 选择服务包
  159. const selectPackage = (pkg) => {
  160. selectedPackage.value = pkg
  161. console.log('选中服务包:', pkg)
  162. }
  163. const getPackageTotalPrice = (pkg) => {
  164. if (!pkg) return 0
  165. if (pkg.num > 0) {
  166. return parseFloat(pkg.jg * pkg.num) || 0
  167. }
  168. return parseFloat(pkg.jg) || 0
  169. }
  170. const formatServiceLine = (item) => {
  171. if (!item) return ''
  172. if (item.sc > 0) return `${item.mc}:${item.sc}分钟`
  173. if (item.cs > 0) return `${item.mc}:${item.cs}次`
  174. if (item.ll > 0) return `${item.mc}:${item.ll}MB`
  175. return `${item.mc}`
  176. }
  177. const getPackageIntroHtml = (pkg) => {
  178. if (!pkg) return ''
  179. if (pkg.introHtml) return pkg.introHtml
  180. const list = pkg?.grfwbmxList || []
  181. const lines = list.map(formatServiceLine).filter(Boolean)
  182. return lines.map((line) => `<p>• ${line}</p>`).join('')
  183. }
  184. const getPackageDetailList = (pkg) => {
  185. if (!pkg) return []
  186. if (Array.isArray(pkg.detailList) && pkg.detailList.length > 0) {
  187. return pkg.detailList
  188. }
  189. const list = pkg?.grfwbmxList || []
  190. return list.map((item) => ({
  191. title: item?.mc || '',
  192. html: `<p>${getServiceDesc(item)}</p>`
  193. }))
  194. }
  195. const getRichNodes = (html) => {
  196. if (!html) return ''
  197. // 通过内联样式控制 rich-text 的基础排版(不同端对外部样式支持不一致)
  198. return `<div style="font-size: 36rpx; line-height: 36rpx; color: #000;">${html}</div>`
  199. }
  200. const getServiceDesc = (item) => {
  201. if (!item) return ''
  202. if (item.sfmf == 1) {
  203. return '该服务为免费项,订阅后可直接使用。'
  204. }
  205. if (item.sc > 0) {
  206. return `包含可用时长:${item.sc} 分钟。`
  207. }
  208. if (item.cs > 0) {
  209. return `包含可用次数:${item.cs} 次。`
  210. }
  211. if (item.ll > 0) {
  212. return `包含可用流量:${item.ll} MB。`
  213. }
  214. return '订阅后可使用该服务。'
  215. }
  216. const openDetail = (pkg) => {
  217. selectPackage(pkg)
  218. termsAccepted.value = false
  219. expandedPackageId.value = String(pkg?.grfwbid || '')
  220. }
  221. const collapseDetail = () => {
  222. expandedPackageId.value = ''
  223. termsAccepted.value = false
  224. }
  225. const isExpanded = (pkg) => String(pkg?.grfwbid || '') === expandedPackageId.value
  226. const toggleExpand = (pkg) => {
  227. const pkgId = String(pkg?.grfwbid || '')
  228. if (!pkgId) return
  229. if (expandedPackageId.value === pkgId) {
  230. collapseDetail()
  231. return
  232. }
  233. openDetail(pkg)
  234. }
  235. const openTermsDrawer = () => {
  236. termsDrawerVisible.value = true
  237. }
  238. const closeTermsDrawer = () => {
  239. termsDrawerVisible.value = false
  240. }
  241. const agreeTerms = () => {
  242. termsAccepted.value = true
  243. termsDrawerVisible.value = false
  244. }
  245. const toggleTermsAccepted = () => {
  246. termsAccepted.value = !termsAccepted.value
  247. }
  248. // 发起支付
  249. const handlePay = async () => {
  250. if (isPaymentInProgress.value) {
  251. console.log('支付进行中,请勿重复点击')
  252. return
  253. }
  254. // 检查是否选择了服务包
  255. if (!selectedPackage.value) {
  256. uni.showToast({
  257. title: '请选择服务包',
  258. icon: 'none'
  259. })
  260. return
  261. }
  262. const amount = finalAmount.value
  263. if (!amount || amount <= 0) {
  264. uni.showToast({
  265. title: '支付金额异常',
  266. icon: 'none'
  267. })
  268. return
  269. }
  270. try {
  271. isPaymentInProgress.value = true
  272. // 1. 创建订单,获取支付参数
  273. console.log('创建个人服务包订单,包ID:', selectedPackage.value.grfwbid)
  274. console.log('支付金额:', amount)
  275. const payParamsRaw = await grfwApi.grfw_prepayGrfwb({
  276. grfwbid: selectedPackage.value.grfwbid
  277. })
  278. console.log('创建订单接口原始返回:', payParamsRaw.data.ssData)
  279. const payParams = normalizePayParams(payParamsRaw)
  280. if (!payParams.timeStamp || !payParams.nonceStr || !payParams.package || !payParams.paySign) {
  281. throw new Error('支付参数不完整')
  282. }
  283. console.log('支付参数:', payParams)
  284. // 2. 调用微信支付
  285. const paymentResult = await uni.requestPayment({
  286. provider: 'wxpay',
  287. timeStamp: String(payParams.timeStamp),
  288. nonceStr: payParams.nonceStr,
  289. package: payParams.package,
  290. signType: payParams.signType || 'RSA',
  291. paySign: payParams.paySign,
  292. })
  293. console.log('支付结果:', paymentResult)
  294. // 3. 支付成功 - 跳转到支付结果页面
  295. const resultOrderId = payParams.orderId
  296. if (resultOrderId) {
  297. // 跳转到支付结果页面,进行订单状态查询,传递 grfwbid 用于确认服务
  298. uni.redirectTo({
  299. url: `/pages/payment/result?orderId=${resultOrderId}&grfwbid=${selectedPackage.value.grfwbid}`
  300. })
  301. } else {
  302. // 如果没有订单ID,直接提示成功
  303. uni.showToast({
  304. title: '支付成功',
  305. icon: 'success',
  306. duration: 2000
  307. })
  308. setTimeout(() => {
  309. uni.navigateBack()
  310. }, 2000)
  311. }
  312. } catch (error) {
  313. console.error('支付失败:', error)
  314. // 处理不同的错误情况
  315. if (error.errMsg) {
  316. // 微信支付相关错误
  317. if (error.errMsg.includes('cancel')) {
  318. uni.showToast({
  319. title: '已取消支付',
  320. icon: 'none'
  321. })
  322. } else if (error.errMsg.includes('fail')) {
  323. uni.showToast({
  324. title: '支付失败,请重试',
  325. icon: 'none'
  326. })
  327. }
  328. } else {
  329. // 其他错误(比如创建订单失败)
  330. uni.showToast({
  331. title: error.message || '操作失败',
  332. icon: 'none'
  333. })
  334. }
  335. } finally {
  336. isPaymentInProgress.value = false
  337. }
  338. }
  339. // 加载个人服务包列表
  340. const loadServicePackages = async () => {
  341. try {
  342. const res = await grfwApi.grfw_initGrfwbBuy({})
  343. console.log(res)
  344. const packages = normalizeServicePackages(res?.data?.ssData)
  345. if (!packages.length) {
  346. throw new Error('暂无可用服务包')
  347. }
  348. servicePackages.value = packages
  349. selectedPackage.value = packages[0] || null
  350. termsAccepted.value = false
  351. expandedPackageId.value = ''
  352. } catch (error) {
  353. console.error('加载服务包失败:', error)
  354. servicePackages.value = []
  355. selectedPackage.value = null
  356. uni.showToast({
  357. title: error?.message || '加载服务包失败',
  358. icon: 'none'
  359. })
  360. }
  361. }
  362. // 页面加载
  363. onLoad((options) => {
  364. console.log('充值页面加载,参数:', options)
  365. // 加载服务包列表
  366. loadServicePackages()
  367. })
  368. // 测试跳转到支付结果页
  369. const goToTestResult = () => {
  370. // 使用固定的订单号测试
  371. const testOrderId = 'pmsgrfwb7m05clpYYgwHG8qlroZB1'
  372. const testGrfwbid = selectedPackage.value?.grfwbid || ''
  373. uni.redirectTo({
  374. url: `/pages/payment/result?orderId=${testOrderId}&grfwbid=${testGrfwbid}`
  375. })
  376. }
  377. </script>
  378. <style lang="scss" scoped>
  379. .subscription-page {
  380. min-height: 100vh;
  381. background: #fff;
  382. padding: 24rpx;
  383. box-sizing: border-box;
  384. }
  385. .card-list {
  386. display: flex;
  387. flex-direction: column;
  388. gap: 30rpx;
  389. }
  390. .service-card {
  391. background: #fafafb;
  392. border-radius: 6rpx;
  393. padding: 40rpx 50rpx 50rpx;
  394. box-shadow: 0 7rpx 10rpx rgba(0, 0, 0, 0.2);
  395. border: 2rpx solid #cbcbcb;
  396. }
  397. .service-card.expanded {
  398. }
  399. .card-price-row {
  400. display: flex;
  401. justify-content: space-between;
  402. align-items: center;
  403. gap: 18rpx;
  404. margin-top: 10rpx;
  405. }
  406. .card-title {
  407. font-size: 48rpx;
  408. // font-weight: 700;
  409. color: #000;
  410. line-height: 48rpx;
  411. }
  412. .card-price {
  413. font-size: 48rpx;
  414. // font-weight: 700;
  415. color: #000;
  416. }
  417. .card-action {
  418. height: 50rpx;
  419. line-height: 50rpx;
  420. width: 110rpx;
  421. border-radius: 6rpx;
  422. background: #3a3e51;
  423. color: #fff;
  424. font-size: 26rpx;
  425. border: none;
  426. margin: 0;
  427. flex-shrink: 0;
  428. }
  429. .card-action::after {
  430. border: none;
  431. }
  432. .card-preview {
  433. margin-top: 18rpx;
  434. display: -webkit-box;
  435. -webkit-line-clamp: 3;
  436. -webkit-box-orient: vertical;
  437. overflow: hidden;
  438. }
  439. .expanded-intro {
  440. // padding-top: 10rpx;
  441. }
  442. .card-preview :deep(p),
  443. .expanded-intro :deep(p) {
  444. margin: 0;
  445. padding: 0;
  446. font-size: 26rpx;
  447. line-height: 36rpx;
  448. color: #000;
  449. }
  450. .detail-item-rt {
  451. margin-top: 8rpx;
  452. }
  453. .detail-item-rt :deep(p),
  454. .detail-item-rt :deep(div),
  455. .detail-item-rt :deep(span),
  456. .detail-item-rt :deep(li) {
  457. margin: 0;
  458. padding: 0;
  459. font-size: 26rpx;
  460. line-height: 36rpx;
  461. color: #000;
  462. }
  463. .detail-item-rt :deep(b),
  464. .detail-item-rt :deep(strong) {
  465. font-weight: 700;
  466. color: #111;
  467. }
  468. .empty-state {
  469. text-align: center;
  470. padding: 80rpx 0;
  471. color: #999;
  472. font-size: 28rpx;
  473. }
  474. .detail-section-title {
  475. margin-top: 40rpx;
  476. margin-bottom: 12rpx;
  477. font-size: 48rpx;
  478. color: #000;
  479. line-height: 48rpx;
  480. }
  481. .detail-item {
  482. padding: 14rpx 0;
  483. }
  484. .detail-item-title {
  485. font-size: 36rpx;
  486. font-weight: bold;
  487. color: #000;
  488. }
  489. .detail-item-desc {
  490. margin-top: 8rpx;
  491. font-size: 26rpx;
  492. line-height: 36rpx;
  493. color: #7b8597;
  494. }
  495. .expanded-body {
  496. margin-top: 18rpx;
  497. }
  498. .terms-text {
  499. font-size: 36rpx;
  500. color: #0030ab;
  501. }
  502. .expanded-footer {
  503. padding-top: 16rpx;
  504. }
  505. .bottom-terms {
  506. display: flex;
  507. align-items: center;
  508. gap: 10rpx;
  509. padding: 0 6rpx 14rpx;
  510. }
  511. .checkbox {
  512. width: 34rpx;
  513. height: 34rpx;
  514. border-radius: 6rpx;
  515. border: 2rpx solid #b8b8b8;
  516. background: #fff;
  517. display: flex;
  518. align-items: center;
  519. justify-content: center;
  520. box-sizing: border-box;
  521. }
  522. .checkbox.checked {
  523. border-color: #3a3e51;
  524. background: #3a3e51;
  525. }
  526. .checkmark {
  527. color: #fff;
  528. font-size: 24rpx;
  529. line-height: 24rpx;
  530. font-weight: 700;
  531. }
  532. .bottom-btn {
  533. width: 100%;
  534. height: 70rpx;
  535. line-height: 70rpx;
  536. border-radius: 6rpx;
  537. background: #3a3e51;
  538. color: #fff;
  539. font-size: 40rpx;
  540. // font-weight: 700;
  541. border: none;
  542. }
  543. .bottom-btn[disabled] {
  544. background: #3a3e51;
  545. color: #fff;
  546. }
  547. .bottom-btn::after {
  548. border: none;
  549. }
  550. .terms-drawer {
  551. background: #fff;
  552. padding: 22rpx 24rpx 0;
  553. max-height: 80vh;
  554. box-sizing: border-box;
  555. display: flex;
  556. flex-direction: column;
  557. }
  558. .drawer-head {
  559. display: flex;
  560. align-items: center;
  561. justify-content: center;
  562. padding-bottom: 16rpx;
  563. border-bottom: 1rpx solid #f0f0f0;
  564. }
  565. .drawer-title {
  566. font-size: 32rpx;
  567. font-weight: 700;
  568. color: #111;
  569. }
  570. .drawer-body {
  571. flex: 1;
  572. min-height: 0;
  573. padding: 16rpx 0;
  574. }
  575. .drawer-text {
  576. padding-right: 10rpx;
  577. }
  578. .drawer-p {
  579. font-size: 26rpx;
  580. line-height: 40rpx;
  581. color: #444;
  582. margin-bottom: 12rpx;
  583. }
  584. .drawer-actions {
  585. margin-top: auto;
  586. padding-top: 14rpx;
  587. }
  588. .drawer-agree {
  589. width: 100%;
  590. height: 88rpx;
  591. line-height: 88rpx;
  592. border-radius: 6rpx;
  593. background: #3a3e51;
  594. color: #fff;
  595. font-size: 30rpx;
  596. font-weight: 700;
  597. border: none;
  598. }
  599. .drawer-agree::after {
  600. border: none;
  601. }
  602. </style>