index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. <template>
  2. <div class="store-page order-detail-page">
  3. <div class="order-detail-page__inner app-page__inner--wide">
  4. <Breadcrumb />
  5. <a-skeleton animation :loading="loading" class="store-spin">
  6. <StoreDetailSkeleton variant="order" />
  7. <template #content>
  8. <div class="detail-stack">
  9. <!-- 待支付横幅 -->
  10. <div v-if="data?.state === 0" class="pay-banner detail-stack__section">
  11. <div class="pay-banner__info">
  12. <icon-clock-circle class="pay-banner__icon" />
  13. <div>
  14. <div class="pay-banner__title">等待支付</div>
  15. <div class="pay-banner__desc">{{ paymentCountdownText }}</div>
  16. </div>
  17. </div>
  18. <div class="pay-banner__actions">
  19. <a-button type="primary" size="large" class="pay-banner__btn" :loading="payLoading" @click="pay">
  20. 继续支付 ¥{{ data?.price }}
  21. </a-button>
  22. <a-button
  23. size="large"
  24. class="pay-banner__btn"
  25. :loading="cancelLoading"
  26. @click="handleCancelOrder"
  27. >
  28. 取消订单
  29. </a-button>
  30. </div>
  31. </div>
  32. <a-card
  33. v-if="data?.state === 0 && data?.canChangePayType !== false"
  34. :bordered="false"
  35. class="payment-method-card detail-stack__section"
  36. title="选择支付方式"
  37. >
  38. <!-- <p class="payment-method-card__hint">可以随时切换,确认后将直接跳转至对应支付页面。</p> -->
  39. <PayMethodPicker v-model="selectedPayType" :methods="availablePaymentMethods" layout="grid" />
  40. <a-button
  41. type="primary"
  42. long
  43. size="large"
  44. class="payment-method-card__submit"
  45. :disabled="!selectedPayType"
  46. :loading="payLoading"
  47. @click="pay"
  48. >
  49. 确认并支付
  50. </a-button>
  51. </a-card>
  52. <a-card :bordered="false" class="status-card detail-stack__section">
  53. <OrderProgressSteps
  54. class="steps"
  55. :mobile="isMobile"
  56. :current="stepCurrent"
  57. :status="stepStatus"
  58. :labels="stepLabels"
  59. :descriptions="stepDescriptions"
  60. />
  61. <div class="status-badge-wrap">
  62. <OrderStateTag :state="data?.state ?? 0" />
  63. </div>
  64. </a-card>
  65. <OrderRefundPanel
  66. v-if="data?.state === 2 && (data?.canRefund || data?.refundDisabledReason)"
  67. class="detail-stack__section"
  68. :can-refund="!!data?.canRefund"
  69. :refund-disabled-reason="data?.refundDisabledReason"
  70. :loading="refundLoading"
  71. :price="data?.price"
  72. :lepao-count="data?.lepao_count ?? 0"
  73. :user-lepao-count="data?.user_lepao_count"
  74. variant="user"
  75. @refund="handleRefundOrder"
  76. />
  77. <a-card :bordered="false" class="info-card detail-stack__section" title="订单信息">
  78. <a-descriptions :column="1" bordered size="medium">
  79. <a-descriptions-item label="商品名称">{{ data?.name || '-' }}</a-descriptions-item>
  80. <a-descriptions-item v-if="data?.original_price && data?.discount_amount > 0" label="商品原价">
  81. ¥{{ data.original_price }}
  82. </a-descriptions-item>
  83. <a-descriptions-item v-if="data?.coupon_code" label="优惠码">
  84. {{ data.coupon_code }}
  85. <span v-if="data?.discount_amount > 0" class="discount-tag">-¥{{ data.discount_amount }}</span>
  86. </a-descriptions-item>
  87. <a-descriptions-item label="支付金额">
  88. <span class="price-text">¥{{ data?.price }}</span>
  89. </a-descriptions-item>
  90. <a-descriptions-item label="支付方式">
  91. <span class="pay-row">
  92. <icon-wechatpay v-if="data?.pay_type === 'wxpay'" />
  93. <icon-alipay-circle v-else-if="data?.pay_type === 'alipay'" />
  94. {{ getPayTypeLabel(data?.pay_type) }}
  95. </span>
  96. </a-descriptions-item>
  97. <a-descriptions-item label="订单号">{{ data?.orderId }}</a-descriptions-item>
  98. <a-descriptions-item v-if="data?.pay_id" label="支付平台单号">{{ data.pay_id }}</a-descriptions-item>
  99. <a-descriptions-item label="下单时间">{{ formatStoreTimeFull(data?.create_time) }}</a-descriptions-item>
  100. <a-descriptions-item v-if="data?.pay_time" label="支付时间">
  101. {{ formatStoreTimeFull(data.pay_time) }}
  102. </a-descriptions-item>
  103. <a-descriptions-item v-if="data?.state === 2 && data?.lepao_count > 0" label="购买次数">
  104. {{ data.lepao_count }} 次
  105. </a-descriptions-item>
  106. <a-descriptions-item v-if="data?.state === 2 && data?.user_lepao_count != null" label="当前剩余次数">
  107. {{ data.user_lepao_count }} 次
  108. </a-descriptions-item>
  109. </a-descriptions>
  110. </a-card>
  111. <a-card v-if="content" :bordered="false" class="info-card detail-stack__section" title="商品说明">
  112. <div class="rich-content" v-html="content" />
  113. </a-card>
  114. <div class="detail-actions detail-stack__section">
  115. <a-button
  116. v-if="data?.state === 0"
  117. status="danger"
  118. :loading="cancelLoading"
  119. @click="handleCancelOrder"
  120. >
  121. 取消订单
  122. </a-button>
  123. <a-button @click="$router.push('/store/myOrder')">返回订单列表</a-button>
  124. <a-button type="outline" @click="$router.push('/store/goodsList')">继续购物</a-button>
  125. </div>
  126. </div>
  127. </template>
  128. </a-skeleton>
  129. </div>
  130. </div>
  131. </template>
  132. <script setup>
  133. import { ref, computed, onUnmounted, onMounted } from 'vue'
  134. import { orderDeatil, cancelOrder, refundOrder, payOrder } from '@/api/order'
  135. import { useRoute } from 'vue-router'
  136. import { Notification, Message, Modal } from '@arco-design/web-vue'
  137. import OrderStateTag from '@/components/store/OrderStateTag.vue'
  138. import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
  139. import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
  140. import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
  141. import PayMethodPicker from '@/components/store/PayMethodPicker.vue'
  142. import {
  143. formatStoreTimeFull,
  144. getPayTypeLabel,
  145. decodeGoodsContent
  146. } from '@/utils/storeFormat'
  147. import { submitPayment } from '@/utils/payment'
  148. const route = useRoute()
  149. const { id } = route.params
  150. const loading = ref(true)
  151. const cancelLoading = ref(false)
  152. const refundLoading = ref(false)
  153. const data = ref({})
  154. const content = ref('')
  155. const paymentCountdownText = ref('')
  156. const isMobile = ref(false)
  157. const selectedPayType = ref('')
  158. const payLoading = ref(false)
  159. const availablePaymentMethods = computed(() => data.value?.paymentMethods || [])
  160. const stepCurrent = ref(1)
  161. const stepStatus = ref('process')
  162. const stepLabels = ref(['待支付', '待处理', '已完成'])
  163. const stepDescriptions = ref(['', '', ''])
  164. const PAY_TIMEOUT_SEC = 300
  165. let pollTimer = null
  166. let countdownTimer = null
  167. let resizeHandler = null
  168. const updateStepState = () => {
  169. const state = data.value?.state
  170. stepDescriptions.value = ['', '', '']
  171. switch (state) {
  172. case 0:
  173. stepCurrent.value = 1
  174. stepStatus.value = 'process'
  175. stepLabels.value = ['待支付', '待处理', '已完成']
  176. updatePaymentCountdown()
  177. startCountdownTicker()
  178. break
  179. case 1:
  180. stepCurrent.value = 2
  181. stepStatus.value = 'process'
  182. stepDescriptions.value[1] = '支付成功,等待系统处理'
  183. stopPendingOrderTimers()
  184. break
  185. case 2:
  186. stepCurrent.value = 3
  187. stepStatus.value = 'finish'
  188. stepDescriptions.value[2] = '权益已发放至账户'
  189. stopPendingOrderTimers()
  190. break
  191. case 5:
  192. stepCurrent.value = 3
  193. stepStatus.value = 'finish'
  194. stepLabels.value = ['待支付', '待处理', '已退款']
  195. stepDescriptions.value[2] = '订单已退款,款项将原路退回'
  196. stopPendingOrderTimers()
  197. break
  198. case 3:
  199. stepCurrent.value = 1
  200. stepStatus.value = 'error'
  201. stepLabels.value = ['已关闭', '待处理', '已完成']
  202. stepDescriptions.value[0] = '订单超时或已取消'
  203. stopPendingOrderTimers()
  204. break
  205. default:
  206. break
  207. }
  208. }
  209. const updatePaymentCountdown = () => {
  210. const ts = data.value?.create_time
  211. if (!ts) {
  212. paymentCountdownText.value = '请在规定时间内完成支付'
  213. return
  214. }
  215. const diffSec = Math.floor((Date.now() - ts) / 1000)
  216. if (diffSec > PAY_TIMEOUT_SEC) {
  217. paymentCountdownText.value = '支付已超时,订单可能已关闭'
  218. stepDescriptions.value[0] = '支付超时'
  219. return
  220. }
  221. const remaining = PAY_TIMEOUT_SEC - diffSec
  222. const m = Math.floor(remaining / 60)
  223. const s = String(remaining % 60).padStart(2, '0')
  224. paymentCountdownText.value = `请在 ${m} 分 ${s} 秒内完成支付`
  225. stepDescriptions.value[0] = paymentCountdownText.value
  226. }
  227. const getOrderDetail = async () => {
  228. try {
  229. const res = await orderDeatil({ orderId: id })
  230. if (!res || res.code !== 0) {
  231. return Notification.error({
  232. title: '获取订单详情失败',
  233. content: res?.msg ?? '请稍后再试'
  234. })
  235. }
  236. data.value = res.data ?? {}
  237. content.value = decodeGoodsContent(res.data?.content)
  238. if (data.value?.state === 0 && !selectedPayType.value) {
  239. selectedPayType.value = data.value.pay_type || availablePaymentMethods.value[0]?.type || 'alipay'
  240. }
  241. updateStepState()
  242. } catch (error) {
  243. Notification.error({
  244. title: '获取订单详情失败',
  245. content: error.message || '请稍后再试'
  246. })
  247. }
  248. }
  249. const startCountdownTicker = () => {
  250. if (countdownTimer) return
  251. countdownTimer = setInterval(() => {
  252. if (data.value?.state !== 0) {
  253. stopCountdownTicker()
  254. return
  255. }
  256. updatePaymentCountdown()
  257. }, 1000)
  258. }
  259. const stopCountdownTicker = () => {
  260. if (countdownTimer) {
  261. clearInterval(countdownTimer)
  262. countdownTimer = null
  263. }
  264. }
  265. const startPolling = () => {
  266. if (pollTimer) return
  267. pollTimer = setInterval(async () => {
  268. if (data.value?.state !== 0) {
  269. stopPolling()
  270. return
  271. }
  272. await getOrderDetail()
  273. }, 3000)
  274. }
  275. const stopPolling = () => {
  276. if (pollTimer) {
  277. clearInterval(pollTimer)
  278. pollTimer = null
  279. }
  280. }
  281. const stopPendingOrderTimers = () => {
  282. stopCountdownTicker()
  283. stopPolling()
  284. }
  285. onMounted(async () => {
  286. const syncMobile = () => {
  287. isMobile.value = window.innerWidth <= 768
  288. }
  289. resizeHandler = syncMobile
  290. syncMobile()
  291. window.addEventListener('resize', resizeHandler)
  292. loading.value = true
  293. await getOrderDetail()
  294. loading.value = false
  295. if (data.value?.state === 0) startPolling()
  296. })
  297. onUnmounted(() => {
  298. stopPendingOrderTimers()
  299. if (resizeHandler) window.removeEventListener('resize', resizeHandler)
  300. })
  301. const pay = async () => {
  302. if (!selectedPayType.value) {
  303. Message.warning('请选择支付方式')
  304. return
  305. }
  306. const paymentWindow = window.open('', '_blank')
  307. if (!paymentWindow) {
  308. Message.warning('浏览器拦截了支付页弹窗,请允许弹窗后重试')
  309. return
  310. }
  311. try {
  312. payLoading.value = true
  313. const res = await payOrder({ orderId: id, pay_type: selectedPayType.value })
  314. if (!res || res.code !== 0 || !submitPayment(res.pay, { targetWindow: paymentWindow })) {
  315. if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
  316. Notification.error({
  317. title: '发起支付失败',
  318. content: res?.msg || '支付信息无效,请稍后重试'
  319. })
  320. }
  321. } catch (error) {
  322. if (paymentWindow && !paymentWindow.closed) paymentWindow.close()
  323. Notification.error({
  324. title: '发起支付失败',
  325. content: error.message || '请稍后重试'
  326. })
  327. } finally {
  328. payLoading.value = false
  329. }
  330. }
  331. const handleCancelOrder = () => {
  332. Modal.confirm({
  333. title: '取消订单',
  334. content: '确定要取消该订单吗?取消后需重新下单购买。',
  335. okText: '确认取消',
  336. cancelText: '再想想',
  337. okButtonProps: { status: 'danger' },
  338. onOk: async () => {
  339. try {
  340. cancelLoading.value = true
  341. const res = await cancelOrder({ orderId: id })
  342. if (!res || res.code !== 0) {
  343. Notification.error({
  344. title: '取消订单失败',
  345. content: res?.msg ?? '请稍后再试'
  346. })
  347. return
  348. }
  349. Message.success(res.msg || '订单已取消')
  350. stopPendingOrderTimers()
  351. await getOrderDetail()
  352. } catch (error) {
  353. Notification.error({
  354. title: '取消订单失败',
  355. content: error.message || '请稍后再试'
  356. })
  357. } finally {
  358. cancelLoading.value = false
  359. }
  360. }
  361. })
  362. }
  363. const handleRefundOrder = () => {
  364. const purchased = data.value?.lepao_count ?? 0
  365. const remaining = data.value?.user_lepao_count ?? 0
  366. Modal.confirm({
  367. title: '申请退款',
  368. content: `退款将扣回 ${purchased} 次乐跑次数(当前剩余 ${remaining} 次),款项原路退回。确定继续吗?`,
  369. okText: '确认退款',
  370. cancelText: '再想想',
  371. okButtonProps: { status: 'warning' },
  372. onOk: async () => {
  373. try {
  374. refundLoading.value = true
  375. const res = await refundOrder({ orderId: id })
  376. if (!res || res.code !== 0) {
  377. Notification.error({
  378. title: '退款失败',
  379. content: res?.msg ?? '请稍后再试'
  380. })
  381. return
  382. }
  383. Message.success(res.msg || '退款成功')
  384. await getOrderDetail()
  385. } catch (error) {
  386. Notification.error({
  387. title: '退款失败',
  388. content: error.message || '请稍后再试'
  389. })
  390. } finally {
  391. refundLoading.value = false
  392. }
  393. }
  394. })
  395. }
  396. </script>
  397. <style lang="less" scoped>
  398. @import '@/styles/store-theme.less';
  399. .detail-stack {
  400. display: flex;
  401. flex-direction: column;
  402. gap: 20px;
  403. width: 100%;
  404. &__section {
  405. margin: 0;
  406. }
  407. :deep(.arco-card) {
  408. margin-bottom: 0;
  409. }
  410. }
  411. .pay-banner {
  412. display: flex;
  413. flex-wrap: wrap;
  414. align-items: center;
  415. justify-content: space-between;
  416. gap: 16px;
  417. padding: 20px 24px;
  418. background: linear-gradient(135deg, #fff7e6 0%, #ffe7ba 100%);
  419. border: 1px solid #ffd591;
  420. border-radius: @store-radius;
  421. &__info {
  422. display: flex;
  423. align-items: center;
  424. gap: 12px;
  425. }
  426. &__icon {
  427. font-size: 32px;
  428. color: rgb(var(--orange-6));
  429. }
  430. &__title {
  431. font-weight: 600;
  432. font-size: 1.1rem;
  433. color: @store-primary;
  434. }
  435. &__desc {
  436. font-size: 0.85rem;
  437. color: @store-text-muted;
  438. margin-top: 4px;
  439. }
  440. &__actions {
  441. display: flex;
  442. flex-wrap: wrap;
  443. gap: 10px;
  444. flex-shrink: 0;
  445. }
  446. &__btn {
  447. border-radius: 999px;
  448. flex-shrink: 0;
  449. }
  450. }
  451. .status-card,
  452. .info-card,
  453. .payment-method-card {
  454. border-radius: @store-radius;
  455. border: 1px solid @store-card-border;
  456. box-shadow: @store-shadow;
  457. }
  458. .payment-method-card {
  459. overflow: hidden;
  460. background:
  461. radial-gradient(circle at 100% 0, fade(@store-accent, 12%) 0, transparent 33%),
  462. @store-card-bg;
  463. &__hint {
  464. margin: 0 0 18px;
  465. color: @store-text-muted;
  466. font-size: 0.9rem;
  467. line-height: 1.6;
  468. }
  469. &__submit {
  470. margin-top: 20px;
  471. height: 46px;
  472. border-radius: 999px;
  473. font-weight: 600;
  474. letter-spacing: 0.04em;
  475. background: linear-gradient(110deg, @store-primary, lighten(@store-primary, 13%)) !important;
  476. border-color: transparent !important;
  477. box-shadow: 0 8px 18px fade(@store-primary, 24%);
  478. }
  479. }
  480. .status-badge-wrap {
  481. text-align: center;
  482. }
  483. .price-text {
  484. font-size: 1.15rem;
  485. font-weight: 700;
  486. color: @store-price;
  487. }
  488. .discount-tag {
  489. margin-left: 8px;
  490. color: rgb(var(--green-6));
  491. font-size: 0.9rem;
  492. }
  493. .pay-row {
  494. display: inline-flex;
  495. align-items: center;
  496. gap: 6px;
  497. }
  498. .rich-content {
  499. line-height: 1.75;
  500. color: @store-text-muted;
  501. :deep(img) {
  502. max-width: 100%;
  503. }
  504. }
  505. .detail-actions {
  506. display: flex;
  507. gap: 12px;
  508. flex-wrap: wrap;
  509. padding-top: 4px;
  510. }
  511. @media (max-width: 768px) {
  512. .pay-banner {
  513. padding: 14px 12px;
  514. }
  515. .detail-actions {
  516. display: grid;
  517. grid-template-columns: 1fr;
  518. }
  519. .detail-actions :deep(.arco-btn) {
  520. width: 100%;
  521. }
  522. }
  523. </style>