result.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. <template>
  2. <view class="payment-result-page">
  3. <!-- 支付状态图标 -->
  4. <view class="status-icon">
  5. <view v-if="paymentStatus === 'success'" class="icon success-icon">
  6. <Icon name="icon-chenggong" size="120" color="#07c160" />
  7. </view>
  8. <view v-else-if="paymentStatus === 'failed'" class="icon failed-icon">
  9. <Icon name="icon-shibai" size="120" color="#ff4d4f" />
  10. </view>
  11. <view v-else class="icon loading-icon">
  12. <u-loading-icon mode="circle" size="80" color="#1890ff"></u-loading-icon>
  13. </view>
  14. </view>
  15. <!-- 支付状态文字 -->
  16. <view class="status-text">
  17. <text v-if="paymentStatus === 'success'" class="success-text">购买成功</text>
  18. <text v-else-if="paymentStatus === 'failed'" class="failed-text">支付失败</text>
  19. <text v-else class="checking-text">正在查询支付结果...</text>
  20. </view>
  21. <!-- 服务包信息(支付成功时显示) -->
  22. <view class="service-info" v-if="paymentStatus === 'success' && serviceResult">
  23. <view class="service-header">
  24. <text class="service-name">{{ serviceResult.mc }}</text>
  25. <text class="service-deadline">有效期至:{{ formattedDeadline }}</text>
  26. </view>
  27. <!-- 服务项目列表 -->
  28. <view class="service-list" v-if="formattedServiceList.length > 0">
  29. <view class="service-list-title">包含服务项目</view>
  30. <view class="service-items">
  31. <view
  32. v-for="(item, index) in formattedServiceList"
  33. :key="index"
  34. class="service-item"
  35. >
  36. <view class="item-name">{{ item.displayName }}</view>
  37. <view class="item-details">
  38. <text v-if="item.sfmf === 1" class="item-tag free">免费</text>
  39. <text v-if="item.zdsc > 0" class="item-tag">{{ item.zdsc }}分钟</text>
  40. <text v-if="item.zdcs > 0" class="item-tag">{{ item.zdcs }}次</text>
  41. <text v-if="item.zdll > 0" class="item-tag">{{ item.zdll }}MB</text>
  42. </view>
  43. </view>
  44. </view>
  45. </view>
  46. </view>
  47. <!-- 订单信息(失败或查询中时显示) -->
  48. <view class="order-info" v-if="orderInfo && paymentStatus !== 'success'">
  49. <view class="info-item">
  50. <text class="label">订单号:</text>
  51. <text class="value">{{ orderInfo.orderId || orderId }}</text>
  52. </view>
  53. <view class="info-item" v-if="orderInfo.amount">
  54. <text class="label">支付金额:</text>
  55. <text class="value amount">¥{{ orderInfo.amount }}</text>
  56. </view>
  57. </view>
  58. <!-- 失败原因 -->
  59. <view class="error-message" v-if="paymentStatus === 'failed' && errorMessage">
  60. <text>{{ errorMessage }}</text>
  61. </view>
  62. <!-- 操作按钮 -->
  63. <view class="action-btns">
  64. <button
  65. v-if="paymentStatus === 'success'"
  66. class="btn primary-btn"
  67. @click="goToHome"
  68. >
  69. 完成
  70. </button>
  71. <button
  72. v-if="paymentStatus === 'failed'"
  73. class="btn primary-btn"
  74. @click="retryPayment"
  75. >
  76. 重新支付
  77. </button>
  78. <button
  79. v-if="paymentStatus === 'checking'"
  80. class="btn secondary-btn"
  81. @click="stopChecking"
  82. >
  83. 稍后查看
  84. </button>
  85. </view>
  86. <!-- 底部提示 -->
  87. <view class="bottom-tips">
  88. <text v-if="paymentStatus === 'success'">服务包已生效,可以开始使用了</text>
  89. <text v-else-if="paymentStatus === 'failed'">如有疑问,请联系客服</text>
  90. <text v-else>正在确认支付结果,请稍候...</text>
  91. </view>
  92. </view>
  93. </template>
  94. <script setup>
  95. import { ref, computed, onUnmounted } from 'vue'
  96. import { onLoad } from '@dcloudio/uni-app'
  97. import { grfwApi } from '@/api/grfw'
  98. import Icon from '@/components/icon/index.vue'
  99. import { toChineseDate } from '@/utils/date'
  100. // 个人服务项目码映射表
  101. const SERVICE_NAME_MAP = {
  102. 1: '到校通知',
  103. 5: '离校通知',
  104. 11: '进入校车通知',
  105. 15: '离开校车通知',
  106. 101: '实时校车位置',
  107. 111: '音频电话',
  108. 115: '视频电话',
  109. 121: '文本留言',
  110. 122: '图片留言',
  111. 123: '音频留言',
  112. 124: '视频留言'
  113. }
  114. const normalizeTradeState = (state) => {
  115. if (state === undefined || state === null) {
  116. return ''
  117. }
  118. return String(state).toUpperCase()
  119. }
  120. const formatAmountValue = (amountInfo) => {
  121. if (amountInfo === undefined || amountInfo === null) {
  122. return ''
  123. }
  124. if (typeof amountInfo === 'number') {
  125. return amountInfo
  126. }
  127. if (typeof amountInfo === 'string') {
  128. const num = Number(amountInfo)
  129. return Number.isNaN(num) ? amountInfo : num
  130. }
  131. if (typeof amountInfo === 'object') {
  132. if (typeof amountInfo.total === 'number') {
  133. // 微信金额单位为分,转回元
  134. return (amountInfo.total / 100).toFixed(2)
  135. }
  136. if (typeof amountInfo.amount === 'number') {
  137. return amountInfo.amount
  138. }
  139. }
  140. return ''
  141. }
  142. const formatDateTime = (value) => {
  143. if (value === undefined || value === null || value === '') {
  144. return ''
  145. }
  146. let dateObj = null
  147. if (value instanceof Date) {
  148. dateObj = value
  149. } else if (typeof value === 'number') {
  150. const num = value < 1e12 ? value * 1000 : value
  151. dateObj = new Date(num)
  152. } else if (typeof value === 'string') {
  153. const direct = new Date(value)
  154. if (!Number.isNaN(direct.getTime())) {
  155. dateObj = direct
  156. } else {
  157. const normalized = value.replace(/-/g, '/').replace('T', ' ')
  158. const parsed = Date.parse(normalized)
  159. if (!Number.isNaN(parsed)) {
  160. dateObj = new Date(parsed)
  161. }
  162. }
  163. }
  164. if (!dateObj || Number.isNaN(dateObj.getTime())) {
  165. return typeof value === 'string' ? value : ''
  166. }
  167. const pad = (num) => String(num).padStart(2, '0')
  168. const year = dateObj.getFullYear()
  169. const month = pad(dateObj.getMonth() + 1)
  170. const day = pad(dateObj.getDate())
  171. const hour = pad(dateObj.getHours())
  172. const minute = pad(dateObj.getMinutes())
  173. const second = pad(dateObj.getSeconds())
  174. return `${year}-${month}-${day} ${hour}:${minute}:${second}`
  175. }
  176. const getTradeStateMessage = (tradeState, fallbackDesc = '') => {
  177. if (!tradeState) {
  178. return fallbackDesc || ''
  179. }
  180. const map = {
  181. SUCCESS: '支付成功',
  182. REFUND: '订单已退款,金额将原路退回',
  183. REFUNDED: '订单已退款,金额将原路退回',
  184. NOTPAY: '订单未支付,等待用户付款',
  185. USERPAYING: '用户支付中,请稍候',
  186. CLOSED: '订单已关闭,如有疑问请联系客服',
  187. REVOKED: '订单已撤销,请重新下单',
  188. PAYERROR: '支付失败,请联系客服或稍后再试'
  189. }
  190. return map[tradeState] || fallbackDesc || ''
  191. }
  192. /**
  193. * 规范化支付状态数据
  194. * 作用:将后端返回的各种不同格式的支付状态数据,统一转换成前端需要的标准格式
  195. *
  196. * 为什么需要这个函数:
  197. * 1. 后端字段名可能是 snake_case (trade_state) 或 camelCase (tradeState)
  198. * 2. 订单号字段可能叫 outTradeNo、out_trade_no、orderId、order_id 等多种名字
  199. * 3. 金额字段可能在 amount、total、payAmount 等不同位置
  200. * 4. 支付状态码不统一:SUCCESS、PAID、FINISHED 都表示成功
  201. *
  202. * @param {Object} res - 后端返回的原始数据
  203. * @param {string} fallbackOrderId - 备用订单号(如果后端没返回订单号)
  204. * @returns {Object} 返回规范化后的数据:
  205. * - status: 'success' | 'failed' | 'checking' - 统一的支付状态
  206. * - order: { orderId, amount, currency, createTime, payTime, statusDesc, raw } - 订单信息
  207. * - message: 状态描述文字
  208. * - tradeState: 原始的交易状态码(大写)
  209. */
  210. const normalizePaymentStatus = (res, fallbackOrderId = '') => {
  211. const source = res?.data || res || {}
  212. // 1. 提取支付状态:兼容 snake_case (trade_state) 和 camelCase (tradeState)
  213. const tradeState = normalizeTradeState(source.tradeState || source.trade_state || source.status)
  214. const statusMessage = getTradeStateMessage(tradeState, source.tradeStateDesc || source.trade_state_desc)
  215. // 2. 提取订单号:尝试多种可能的字段名
  216. const orderId =
  217. source.outTradeNo ||
  218. source.out_trade_no ||
  219. source.orderId ||
  220. source.order_id ||
  221. source.orderNo ||
  222. source.order_no ||
  223. source.id ||
  224. source.billNo ||
  225. source.bill_no ||
  226. fallbackOrderId
  227. // 3. 构建标准化的订单对象
  228. const normalizedOrder = {
  229. orderId,
  230. amount: formatAmountValue(source.amount ?? source.total ?? source.payAmount),
  231. currency: source.amount?.currency,
  232. createTime: formatDateTime(source.createTime || source.timeEnd || ''),
  233. payTime: formatDateTime(source.successTime || source.payTime || ''),
  234. statusDesc: statusMessage || source.message || '',
  235. raw: source // 保存原始数据,方便调试
  236. }
  237. // 4. 判断支付状态:将各种状态码统一映射为 success/failed/checking
  238. let normalizedStatus = 'checking'
  239. if (['SUCCESS', 'PAID', 'FINISHED', 'PAY_SUCCESS', 'COMPLETED', '1'].includes(tradeState)) {
  240. normalizedStatus = 'success'
  241. } else if (
  242. ['FAIL', 'FAILED', 'PAYERROR', 'CLOSED', 'REVOKED', 'REFUND', 'REFUNDED', '-1'].includes(tradeState)
  243. ) {
  244. normalizedStatus = 'failed'
  245. }
  246. return {
  247. status: normalizedStatus,
  248. order: normalizedOrder,
  249. message: statusMessage || normalizedOrder.statusDesc || '',
  250. tradeState
  251. }
  252. }
  253. const orderId = ref('') // 订单ID
  254. const grfwbid = ref('') // 个人服务包ID
  255. const paymentStatus = ref('checking') // 支付状态: checking, success, failed
  256. const orderInfo = ref(null) // 订单详情
  257. const serviceResult = ref(null) // 服务包确认结果
  258. const errorMessage = ref('') // 错误信息
  259. let checkTimer = null // 轮询定时器
  260. let checkCount = 0 // 查询次数
  261. const MAX_CHECK_COUNT = 20 // 最大查询次数(20次 * 2秒 = 40秒)
  262. // 格式化有效期日期
  263. const formattedDeadline = computed(() => {
  264. if (!serviceResult.value || !serviceResult.value.jzsj) {
  265. return ''
  266. }
  267. return toChineseDate(serviceResult.value.jzsj)
  268. })
  269. // 格式化服务列表(翻译 grfwxmm 服务项目码)
  270. const formattedServiceList = computed(() => {
  271. if (!serviceResult.value || !serviceResult.value.grfwList) {
  272. return []
  273. }
  274. return serviceResult.value.grfwList.map(item => ({
  275. ...item,
  276. displayName: SERVICE_NAME_MAP[item.grfwxmm] || `未知服务(${item.grfwxmm})`
  277. }))
  278. })
  279. // 页面加载
  280. onLoad((options) => {
  281. console.log('支付结果页参数:', options)
  282. orderId.value = options.orderId
  283. grfwbid.value = options.grfwbid
  284. if (!orderId.value) {
  285. uni.showToast({
  286. title: '订单号不存在',
  287. icon: 'none'
  288. })
  289. setTimeout(() => {
  290. uni.navigateBack()
  291. }, 1500)
  292. return
  293. }
  294. // 开始轮询查询订单状态
  295. startCheckPaymentStatus()
  296. })
  297. // 开始轮询查询支付状态
  298. const startCheckPaymentStatus = () => {
  299. console.log('开始查询支付状态')
  300. checkPaymentStatus()
  301. // 每2秒查询一次
  302. checkTimer = setInterval(() => {
  303. checkCount++
  304. if (checkCount >= MAX_CHECK_COUNT) {
  305. // 超过最大查询次数,停止查询
  306. console.log('查询超时,停止查询')
  307. stopChecking()
  308. uni.showModal({
  309. title: '提示',
  310. content: '支付结果查询超时,请稍后在订单列表中查看',
  311. showCancel: false,
  312. success: () => {
  313. uni.navigateBack()
  314. }
  315. })
  316. return
  317. }
  318. checkPaymentStatus()
  319. }, 2000)
  320. }
  321. // 查询支付状态
  322. const checkPaymentStatus = async () => {
  323. try {
  324. const res = await grfwApi.chkWechatpayBySs({
  325. outTradeNo: orderId.value
  326. })
  327. console.log('查询支付状态结果:', res)
  328. // 数据在 res.data.ssData 里
  329. const paymentData = res?.data?.ssData || res?.data || res
  330. const normalized = normalizePaymentStatus({ data: paymentData }, orderId.value)
  331. orderInfo.value = normalized.order
  332. if (normalized.status === 'success') {
  333. paymentStatus.value = 'success'
  334. clearCheckTimer()
  335. // 支付成功后,调用确认服务接口
  336. await confirmService()
  337. } else if (normalized.status === 'failed') {
  338. paymentStatus.value = 'failed'
  339. errorMessage.value = normalized.message || '支付失败'
  340. clearCheckTimer()
  341. } else {
  342. console.log('订单未完成,继续查询... 当前状态:', normalized.tradeState)
  343. }
  344. } catch (error) {
  345. console.error('查询支付状态失败:', error)
  346. // 查询失败不立即停止,继续尝试
  347. }
  348. }
  349. // 确认服务包购买
  350. const confirmService = async () => {
  351. if (!grfwbid.value) {
  352. console.log('没有 grfwbid,跳过确认服务')
  353. return
  354. }
  355. try {
  356. console.log('调用确认服务接口,grfwbid:', grfwbid.value)
  357. const res = await grfwApi.grfw_endGrfwbBuy({
  358. grfwbid: grfwbid.value
  359. })
  360. console.log('确认服务返回:', res)
  361. // 解析返回数据
  362. if (res && res.data && res.data.ssData) {
  363. const ssData = res.data.ssData
  364. // ssData 可能是数组或对象
  365. const resultData = Array.isArray(ssData) ? ssData[0] : ssData
  366. serviceResult.value = resultData
  367. console.log('服务包确认结果:', serviceResult.value)
  368. }
  369. } catch (error) {
  370. console.error('确认服务失败:', error)
  371. }
  372. }
  373. // 清除定时器
  374. const clearCheckTimer = () => {
  375. if (checkTimer) {
  376. clearInterval(checkTimer)
  377. checkTimer = null
  378. }
  379. }
  380. // 停止查询
  381. const stopChecking = () => {
  382. clearCheckTimer()
  383. uni.navigateBack()
  384. }
  385. // 返回首页
  386. const goToHome = () => {
  387. uni.reLaunch({
  388. url: '/pages/main/index'
  389. })
  390. }
  391. // 重新支付
  392. const retryPayment = () => {
  393. // 返回充值页面
  394. uni.navigateBack()
  395. }
  396. // 页面卸载时清除定时器
  397. onUnmounted(() => {
  398. clearCheckTimer()
  399. })
  400. </script>
  401. <style lang="scss" scoped>
  402. .payment-result-page {
  403. min-height: 100vh;
  404. background: #f5f5f5;
  405. padding: 80rpx 30rpx 30rpx;
  406. display: flex;
  407. flex-direction: column;
  408. align-items: center;
  409. }
  410. .status-icon {
  411. margin-bottom: 40rpx;
  412. .icon {
  413. width: 160rpx;
  414. height: 160rpx;
  415. display: flex;
  416. align-items: center;
  417. justify-content: center;
  418. }
  419. }
  420. .status-text {
  421. margin-bottom: 60rpx;
  422. text {
  423. font-size: 40rpx;
  424. font-weight: bold;
  425. }
  426. .success-text {
  427. color: #07c160;
  428. }
  429. .failed-text {
  430. color: #ff4d4f;
  431. }
  432. .checking-text {
  433. color: #1890ff;
  434. }
  435. }
  436. .service-info {
  437. width: 100%;
  438. background: #fff;
  439. border-radius: 20rpx;
  440. padding: 40rpx 30rpx;
  441. margin-bottom: 30rpx;
  442. }
  443. .service-header {
  444. display: flex;
  445. flex-direction: column;
  446. align-items: center;
  447. padding-bottom: 30rpx;
  448. border-bottom: 1rpx solid #f0f0f0;
  449. }
  450. .service-name {
  451. font-size: 36rpx;
  452. font-weight: bold;
  453. color: #07c160;
  454. margin-bottom: 16rpx;
  455. }
  456. .service-deadline {
  457. font-size: 26rpx;
  458. color: #666;
  459. }
  460. .service-list {
  461. padding-top: 30rpx;
  462. }
  463. .service-list-title {
  464. font-size: 28rpx;
  465. color: #333;
  466. font-weight: bold;
  467. margin-bottom: 20rpx;
  468. }
  469. .service-items {
  470. display: grid;
  471. grid-template-columns: repeat(2, 1fr);
  472. gap: 16rpx;
  473. }
  474. .service-item {
  475. display: flex;
  476. flex-direction: column;
  477. gap: 8rpx;
  478. padding: 16rpx;
  479. background: #f6ffed;
  480. border-radius: 8rpx;
  481. border: 1rpx solid #b7eb8f;
  482. }
  483. .item-name {
  484. font-size: 28rpx;
  485. color: #333;
  486. font-weight: 500;
  487. }
  488. .item-details {
  489. display: flex;
  490. flex-wrap: wrap;
  491. gap: 8rpx;
  492. }
  493. .item-tag {
  494. font-size: 22rpx;
  495. color: #666;
  496. background: #fff;
  497. padding: 4rpx 10rpx;
  498. border-radius: 4rpx;
  499. border: 1rpx solid #e5e5e5;
  500. }
  501. .item-tag.free {
  502. color: #52c41a;
  503. background: #fff;
  504. border-color: #52c41a;
  505. }
  506. .order-info {
  507. width: 100%;
  508. background: #fff;
  509. border-radius: 20rpx;
  510. padding: 40rpx 30rpx;
  511. margin-bottom: 30rpx;
  512. }
  513. .info-item {
  514. display: flex;
  515. justify-content: space-between;
  516. align-items: center;
  517. padding: 20rpx 0;
  518. border-bottom: 1rpx solid #f0f0f0;
  519. &:last-child {
  520. border-bottom: none;
  521. }
  522. }
  523. .info-item .label {
  524. font-size: 28rpx;
  525. color: #666;
  526. }
  527. .info-item .value {
  528. font-size: 28rpx;
  529. color: #333;
  530. &.amount {
  531. font-size: 36rpx;
  532. font-weight: bold;
  533. color: #ff4d4f;
  534. }
  535. }
  536. .error-message {
  537. width: 100%;
  538. padding: 20rpx 30rpx;
  539. background: #fff2f0;
  540. border: 1rpx solid #ffccc7;
  541. border-radius: 12rpx;
  542. margin-bottom: 30rpx;
  543. text {
  544. font-size: 26rpx;
  545. color: #ff4d4f;
  546. line-height: 40rpx;
  547. }
  548. }
  549. .action-btns {
  550. width: 100%;
  551. margin-top: 40rpx;
  552. margin-bottom: 40rpx;
  553. }
  554. .btn {
  555. width: 100%;
  556. height: 88rpx;
  557. line-height: 88rpx;
  558. border-radius: 44rpx;
  559. font-size: 32rpx;
  560. font-weight: bold;
  561. border: none;
  562. margin-bottom: 20rpx;
  563. &::after {
  564. border: none;
  565. }
  566. }
  567. .primary-btn {
  568. background: linear-gradient(135deg, #07c160 0%, #05a050 100%);
  569. color: #fff;
  570. box-shadow: 0 8rpx 20rpx rgba(7, 193, 96, 0.3);
  571. }
  572. .secondary-btn {
  573. background: #fff;
  574. color: #666;
  575. border: 2rpx solid #e5e5e5;
  576. }
  577. .bottom-tips {
  578. margin-top: 20rpx;
  579. text {
  580. font-size: 26rpx;
  581. color: #999;
  582. line-height: 40rpx;
  583. }
  584. }
  585. </style>