pay.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <view class="hotel-customer-theme pay-page">
  3. <!-- 加载中 -->
  4. <view v-if="loading" class="pay-loading">
  5. <text>加载中...</text>
  6. </view>
  7. <!-- 金额展示 -->
  8. <view v-else class="pay-hero">
  9. <text class="pay-label">支付金额</text>
  10. <view class="pay-amount">
  11. <text class="pay-yen">¥</text>
  12. <text class="pay-num">{{ amount }}</text>
  13. </view>
  14. <text class="pay-desc">客房送餐服务</text>
  15. </view>
  16. <!-- 倒计时 -->
  17. <view v-if="!loading && remainSeconds > 0" class="pay-countdown">
  18. <text class="pc-label">请在</text>
  19. <text class="pc-time">{{ countdownText }}</text>
  20. <text class="pc-label">内完成支付,超时订单将自动取消</text>
  21. </view>
  22. <view v-else-if="!loading && remainSeconds <= 0" class="pay-countdown pay-countdown--expired">
  23. <text class="pc-label">订单已超时,请重新下单</text>
  24. </view>
  25. <!-- 订单信息 -->
  26. <view v-if="!loading && orderId" class="pay-info-card">
  27. <view class="pi-line">
  28. <text class="pi-label">订单编号</text>
  29. <text class="pi-value">{{ orderNo }}</text>
  30. </view>
  31. <view class="pi-line">
  32. <text class="pi-label">送至</text>
  33. <text class="pi-value">{{ roomNo }}</text>
  34. </view>
  35. </view>
  36. <!-- 支付按钮 -->
  37. <view
  38. v-if="!loading && remainSeconds > 0 && !paying && !paySuccess"
  39. class="pay-btn"
  40. @click="doPay"
  41. >立即支付 ¥{{ amount }}</view>
  42. <view v-else-if="!loading && paying" class="pay-btn pay-btn--disabled">支付中...</view>
  43. <view v-else-if="!loading && paySuccess" class="pay-btn pay-btn--success">支付成功 ✓</view>
  44. <view v-else-if="!loading" class="pay-btn pay-btn--disabled">订单已超时</view>
  45. <!-- 返回修改 -->
  46. <view v-if="!loading && !paying && !paySuccess" class="pay-back" @click="goBack">← 返回修改</view>
  47. </view>
  48. </template>
  49. <script setup>
  50. import { ref, computed, onMounted, onUnmounted } from 'vue'
  51. import { useHotelOrderStore } from '@/store'
  52. import api from '@/api'
  53. const orderStore = useHotelOrderStore()
  54. const orderId = ref('')
  55. const orderNo = ref('')
  56. const roomNo = ref('')
  57. const amount = ref('0.00')
  58. const paying = ref(false)
  59. const paySuccess = ref(false)
  60. const remainSeconds = ref(900) // 15分钟 = 900秒
  61. const loading = ref(true)
  62. let countdownTimer = null
  63. let pollTimer = null
  64. // 支付环境自动识别(paySource 用于 doPay 判断支付渠道)
  65. const paySource = ref('MOCK')
  66. onMounted(() => {
  67. const pages = getCurrentPages()
  68. const currentPage = pages[pages.length - 1]
  69. const options = currentPage?.$page?.options || currentPage?.options || {}
  70. orderId.value = options.orderId || ''
  71. const tenantId = orderStore.tenantId
  72. // 从后端查询订单详情,获取真实金额和订单信息
  73. if (orderId.value && tenantId) {
  74. api.hotelOrderDetail(orderId.value, tenantId).then(function(res) {
  75. var data = res?.data || res
  76. if (data) {
  77. orderNo.value = data.orderNo || ''
  78. roomNo.value = data.roomNo || orderStore.roomInfo?.roomNo || ''
  79. amount.value = data.totalAmount ? String(data.totalAmount) : '0.00'
  80. // 如果订单已支付成功(从支付宝返回后重新进入),直接跳转
  81. if (data.payStatus === 1) {
  82. loading.value = false
  83. paySuccess.value = true
  84. stopCountdown()
  85. uni.showToast({ title: '支付成功', icon: 'success' })
  86. setTimeout(function() {
  87. uni.redirectTo({
  88. url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
  89. })
  90. }, 1200)
  91. return
  92. }
  93. // 根据后端返回的支付截止时间计算剩余秒数
  94. if (data.payExpireTime) {
  95. var expireTime = new Date(data.payExpireTime).getTime()
  96. var now = Date.now()
  97. var diff = Math.floor((expireTime - now) / 1000)
  98. remainSeconds.value = diff > 0 ? diff : 0
  99. }
  100. }
  101. loading.value = false
  102. startCountdown()
  103. // 订单未支付时,自动启动轮询检测支付状态(用于从支付宝返回后的场景)
  104. startPayPolling()
  105. }).catch(function() {
  106. loading.value = false
  107. uni.showToast({ title: '加载订单信息失败', icon: 'none' })
  108. })
  109. } else {
  110. loading.value = false
  111. }
  112. // 自动识别支付环境
  113. // #ifdef MP-WEIXIN
  114. paySource.value = 'WECHAT'
  115. // #endif
  116. // #ifdef MP-ALIPAY
  117. paySource.value = 'ALIPAY'
  118. // #endif
  119. // #ifdef H5
  120. const ua = navigator.userAgent.toLowerCase()
  121. if (ua.includes('micromessenger')) {
  122. paySource.value = 'WECHAT'
  123. } else {
  124. paySource.value = 'ALIPAY'
  125. }
  126. // #endif
  127. startCountdown()
  128. })
  129. onUnmounted(() => {
  130. stopCountdown()
  131. stopPolling()
  132. })
  133. /** 启动倒计时 */
  134. function startCountdown() {
  135. stopCountdown()
  136. countdownTimer = setInterval(() => {
  137. if (remainSeconds.value > 0) {
  138. remainSeconds.value--
  139. } else {
  140. stopCountdown()
  141. }
  142. }, 1000)
  143. }
  144. function stopCountdown() {
  145. if (countdownTimer) {
  146. clearInterval(countdownTimer)
  147. countdownTimer = null
  148. }
  149. }
  150. function stopPolling() {
  151. if (pollTimer) {
  152. clearTimeout(pollTimer)
  153. pollTimer = null
  154. }
  155. }
  156. /** 倒计时格式化 */
  157. const countdownText = computed(() => {
  158. const m = Math.floor(remainSeconds.value / 60)
  159. const s = remainSeconds.value % 60
  160. return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
  161. })
  162. /** 执行支付 */
  163. async function doPay() {
  164. if (paying.value || paySuccess.value) return
  165. if (remainSeconds.value <= 0) {
  166. uni.showToast({ title: '订单已超时', icon: 'none' })
  167. return
  168. }
  169. paying.value = true
  170. try {
  171. var tenantId = orderStore.tenantId
  172. // 先调用后端创建支付流水,获取 tradeNo
  173. var createRes = await api.hotelPayCreate(orderId.value, tenantId, paySource.value)
  174. var createData = createRes?.data || createRes
  175. if (!createData) {
  176. paying.value = false
  177. uni.showToast({ title: '创建支付失败', icon: 'none' })
  178. return
  179. }
  180. // 根据支付渠道拉起支付
  181. if (paySource.value === 'ALIPAY' && createData.payForm) {
  182. // 支付宝 H5 手机网站支付:后端返回 payForm(HTML 表单),渲染后自动提交跳转支付宝收银台
  183. // #ifdef H5
  184. var payContainer = document.getElementById('alipay-pay-form')
  185. if (!payContainer) {
  186. payContainer = document.createElement('div')
  187. payContainer.id = 'alipay-pay-form'
  188. payContainer.style.display = 'none'
  189. document.body.appendChild(payContainer)
  190. }
  191. payContainer.innerHTML = createData.payForm
  192. var form = payContainer.querySelector('form')
  193. if (form) {
  194. form.submit()
  195. } else {
  196. paying.value = false
  197. uni.showToast({ title: '支付表单异常', icon: 'none' })
  198. }
  199. // 提交后页面会跳转到支付宝收银台,用户支付完成后通过 return-url 返回
  200. // 同时启动轮询,用户返回后自动检测支付状态
  201. startPayPolling()
  202. // #endif
  203. // #ifdef MP-ALIPAY
  204. // 支付宝小程序:调用 my.tradePay 拉起收银台
  205. my.tradePay({
  206. tradeNO: createData.tradeNo,
  207. success: function(res) {
  208. startPayPolling()
  209. },
  210. fail: function(err) {
  211. paying.value = false
  212. uni.showToast({ title: '支付取消或失败', icon: 'none' })
  213. }
  214. })
  215. // #endif
  216. } else if (paySource.value === 'ALIPAY' && createData.tradeNo) {
  217. // 支付宝小程序环境:有 tradeNo 无 payForm,用 my.tradePay
  218. // #ifdef MP-ALIPAY
  219. my.tradePay({
  220. tradeNO: createData.tradeNo,
  221. success: function(res) {
  222. startPayPolling()
  223. },
  224. fail: function(err) {
  225. paying.value = false
  226. uni.showToast({ title: '支付取消或失败', icon: 'none' })
  227. }
  228. })
  229. // #endif
  230. // #ifdef H5
  231. // H5 环境不应只有 tradeNo,启动轮询兜底
  232. startPayPolling()
  233. // #endif
  234. } else {
  235. // MOCK 模式:直接调用模拟支付(开发调试用)
  236. var mockRes = await api.hotelPayMockSuccess(orderId.value, tenantId)
  237. var mockData = mockRes?.data || mockRes
  238. if (mockData && mockData.payStatus === 1) {
  239. paySuccess.value = true
  240. stopCountdown()
  241. uni.showToast({ title: '支付成功', icon: 'success' })
  242. setTimeout(function() {
  243. uni.redirectTo({
  244. url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
  245. })
  246. }, 1200)
  247. } else {
  248. paying.value = false
  249. uni.showToast({ title: '支付失败,请重试', icon: 'none' })
  250. }
  251. }
  252. } catch (err) {
  253. paying.value = false
  254. uni.showToast({ title: err?.message || '支付失败', icon: 'none' })
  255. }
  256. }
  257. /** 支付后轮询后端查询真实支付状态 */
  258. function startPayPolling() {
  259. stopPolling()
  260. var pollCount = 0
  261. var maxPoll = 60 // 最多轮询60次,每次2秒,共120秒
  262. // 延迟3秒后开始第一次轮询,给支付宝异步回调留出处理时间
  263. pollTimer = setTimeout(function poll() {
  264. pollCount++
  265. api.hotelPayStatus(orderId.value, orderStore.tenantId).then(function(res) {
  266. var data = res?.data || res
  267. if (data && data.payStatus === 1) {
  268. // 支付成功
  269. stopPolling()
  270. paySuccess.value = true
  271. paying.value = false
  272. stopCountdown()
  273. uni.showToast({ title: '支付成功', icon: 'success' })
  274. setTimeout(function() {
  275. uni.redirectTo({
  276. url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
  277. })
  278. }, 1200)
  279. } else if (pollCount >= maxPoll) {
  280. // 轮询超时,主动查询支付宝侧订单状态(底机制)
  281. stopPolling()
  282. paying.value = false
  283. queryAlipayTradeStatus()
  284. } else {
  285. // 未支付,继续轮询
  286. pollTimer = setTimeout(poll, 2000)
  287. }
  288. }).catch(function() {
  289. // 静默失败,继续轮询
  290. if (pollCount < maxPoll) {
  291. pollTimer = setTimeout(poll, 2000)
  292. } else {
  293. // 轮询超时且请求失败,也尝试主动查询支付宝
  294. stopPolling()
  295. paying.value = false
  296. queryAlipayTradeStatus()
  297. }
  298. })
  299. }, pollCount === 0 ? 3000 : 2000)
  300. }
  301. /** 轮询超时后,主动查询支付宝侧订单状态(底机制) */
  302. function queryAlipayTradeStatus() {
  303. uni.showLoading({ title: '正在确认支付结果...' })
  304. api.hotelPayAlipayQuery(orderStore.tenantId, orderId.value).then(function(res) {
  305. uni.hideLoading()
  306. var data = res?.data || res
  307. if (data && data.payStatus === 1) {
  308. // 支付宝侧已支付,补偿成功
  309. paySuccess.value = true
  310. paying.value = false
  311. stopCountdown()
  312. uni.showToast({ title: '支付成功', icon: 'success' })
  313. setTimeout(function() {
  314. uni.redirectTo({
  315. url: '/pages/hotel/customer/order-status?orderId=' + orderId.value,
  316. })
  317. }, 1200)
  318. } else {
  319. // 支付宝侧也未支付
  320. uni.showModal({
  321. title: '支付结果未知',
  322. content: '暂时无法确认支付状态,请稍后在订单列表中查看。如已扣款请联系客服。',
  323. showCancel: false
  324. })
  325. }
  326. }).catch(function() {
  327. uni.hideLoading()
  328. uni.showModal({
  329. title: '查询失败',
  330. content: '网络异常,请稍后在订单列表中查看支付结果。',
  331. showCancel: false
  332. })
  333. })
  334. }
  335. function goBack() {
  336. // 购物车已清空,返回菜单页重新点餐
  337. uni.redirectTo({ url: '/pages/hotel/customer/menu' })
  338. }
  339. </script>
  340. <style lang="scss" scoped>
  341. .pay-page {
  342. min-height: 100vh;
  343. background: var(--h-bg, #F7F7F7);
  344. display: flex;
  345. flex-direction: column;
  346. align-items: center;
  347. padding: 0 28rpx;
  348. }
  349. /* 加载中 */
  350. .pay-loading {
  351. display: flex;
  352. justify-content: center;
  353. align-items: center;
  354. min-height: 400rpx;
  355. color: var(--h-txt-light, #999);
  356. font-size: 28rpx;
  357. }
  358. /* 金额区 */
  359. .pay-hero {
  360. display: flex;
  361. flex-direction: column;
  362. align-items: center;
  363. padding: 60rpx 0 32rpx;
  364. }
  365. .pay-label {
  366. font-size: 26rpx;
  367. color: var(--h-txt-sub, #666);
  368. margin-bottom: 16rpx;
  369. }
  370. .pay-amount {
  371. display: flex;
  372. align-items: baseline;
  373. margin-bottom: 12rpx;
  374. }
  375. .pay-yen {
  376. font-size: 48rpx;
  377. font-weight: 700;
  378. color: var(--h-pri, #2D5016);
  379. }
  380. .pay-num {
  381. font-size: 88rpx;
  382. font-weight: 800;
  383. color: var(--h-pri, #2D5016);
  384. line-height: 1;
  385. }
  386. .pay-desc {
  387. font-size: 24rpx;
  388. color: var(--h-txt-light, #999);
  389. }
  390. /* 倒计时 */
  391. .pay-countdown {
  392. display: flex;
  393. align-items: center;
  394. gap: 8rpx;
  395. padding: 16rpx 32rpx;
  396. background: #FFF7ED;
  397. border-radius: 40rpx;
  398. margin-bottom: 24rpx;
  399. border: 1rpx solid #FED7AA;
  400. }
  401. .pay-countdown--expired {
  402. background: #FEF2F2;
  403. border-color: #FECACA;
  404. }
  405. .pc-label {
  406. font-size: 22rpx;
  407. color: var(--h-txt-sub, #666);
  408. }
  409. .pc-time {
  410. font-size: 28rpx;
  411. font-weight: 700;
  412. color: var(--h-warn, #D97706);
  413. font-variant-numeric: tabular-nums;
  414. }
  415. /* 订单信息 */
  416. .pay-info-card {
  417. width: 100%;
  418. background: #fff;
  419. border-radius: var(--h-rad, 20rpx);
  420. padding: 24rpx 28rpx;
  421. margin-bottom: 32rpx;
  422. box-shadow: var(--h-shd, 0 2rpx 8rpx rgba(0, 0, 0, 0.04));
  423. }
  424. .pi-line {
  425. display: flex;
  426. justify-content: space-between;
  427. padding: 8rpx 0;
  428. font-size: 26rpx;
  429. }
  430. .pi-label {
  431. color: var(--h-txt-light, #999);
  432. }
  433. .pi-value {
  434. color: var(--h-txt, #1A1A1A);
  435. font-weight: 500;
  436. }
  437. /* 支付按钮 */
  438. .pay-btn {
  439. width: 100%;
  440. box-sizing: border-box;
  441. padding: 28rpx;
  442. border-radius: 48rpx;
  443. background: #07C160;
  444. color: #fff;
  445. font-size: 32rpx;
  446. font-weight: 700;
  447. text-align: center;
  448. box-shadow: 0 8rpx 24rpx rgba(7, 193, 96, 0.3);
  449. margin-top: 8rpx;
  450. }
  451. .pay-btn:active {
  452. opacity: 0.85;
  453. }
  454. .pay-btn--disabled {
  455. opacity: 0.6;
  456. background: #9CA3AF;
  457. box-shadow: none;
  458. }
  459. .pay-btn--success {
  460. background: #16A34A;
  461. box-shadow: 0 8rpx 24rpx rgba(22, 163, 74, 0.3);
  462. }
  463. .pay-back {
  464. margin-top: 32rpx;
  465. font-size: 24rpx;
  466. color: var(--h-txt-light, #999);
  467. padding: 16rpx;
  468. }
  469. </style>