pay.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. <!-- 支付方式 -->
  2. <template>
  3. <v-card elevation="0" :loading="loading" :disabled="loading">
  4. <!-- 加载样式 -->
  5. <template v-slot:loader="{ isActive }">
  6. <v-progress-linear
  7. :active="isActive"
  8. color="var(--v-primary-base)"
  9. height="1"
  10. indeterminate
  11. ></v-progress-linear>
  12. </template>
  13. <div>{{ countdown }}</div>
  14. <div style="color: var(--v-error-base); font-weight: bold; text-align: center;">
  15. <span class="font-size-13 mr-2">¥</span>
  16. <span class="font-size-40"> {{ orderInfo?.price ? orderInfo?.price / 100 : 0 }}</span>
  17. </div>
  18. <template v-if="payMethods?.length">
  19. <v-chip-group v-model="payment" selected-class="text-primary" column mandatory @update:modelValue="payTypeChange">
  20. <v-chip filter v-for="k in payMethods" :key="k.code" :value="k.code" class="mr-3" label>
  21. {{ k.name }}
  22. <svg-icon v-if="k.icon" class="ml-1" :name="k.icon" :size="k.size"></svg-icon>
  23. </v-chip>
  24. </v-chip-group>
  25. <div v-if="tip" style="text-align: center;" class="mt-2">{{ tip }}</div>
  26. <div v-if="isQrCodePay && remainder<=0" style="text-align: center;" class="my-10">
  27. 二维码失效,请重试!
  28. <!-- 二维码刷新 -->
  29. <span @click="submitOrderFun(true)">
  30. <v-icon size="20" style="color: var(--v-primary-base)">mdi-refresh</v-icon>
  31. <span class="text-decoration-underline cursor-pointer mr-2" style="color: var(--v-primary-base)">刷新</span>
  32. </span>
  33. </div>
  34. <div>
  35. <!-- 钱包支付 -->
  36. <div v-if="isWalletPay" class="py-10" style="text-align: center;">
  37. <div>
  38. <span>剩余现金:</span>
  39. <span style="color: var(--v-primary-base);">{{ balance ? (balance / 100.0).toFixed(2) : 0 }}</span>
  40. </div>
  41. <!-- 余额刷新 -->
  42. <div class="mt-3" @click="updateAccountInfo">
  43. <v-icon size="20" style="color: var(--v-primary-base)">mdi-refresh</v-icon>
  44. <span class="text-decoration-underline cursor-pointer mr-2" style="color: var(--v-primary-base)">刷新</span>
  45. </div>
  46. <div class="my-3" v-if="notEnoughMoney">
  47. <span class="color-error">
  48. 余额不足,请微信扫码付款
  49. <span class="text-decoration-underline cursor-pointer" @click="open">(充值)</span>
  50. </span>
  51. </div>
  52. </div>
  53. <!-- 模拟支付 -->
  54. <div v-if="payment === 'mock'" class="py-10"></div>
  55. <!-- 二维码支付 -->
  56. <div v-if="isQrCodePay && remainder>0" style="text-align: center;">
  57. <QrCode :text="payQrCodeTxt" :width="170" style="margin: 0 auto;" />
  58. <div
  59. v-if="payQrCodeTxt"
  60. class="mb-5"
  61. style="color: var(--v-error-base);"
  62. >
  63. 扫码支付时请勿离开
  64. <span v-if="remainderZhShow">{{ remainderZhShow }}</span>
  65. </div>
  66. </div>
  67. <!-- 钱包支付确认按钮 -->
  68. <div v-if="(isWalletPay && !notEnoughMoney) || payment === 'mock'" class="mt-2" style="text-align: center;">
  69. <v-btn
  70. class="buttons" color="primary"
  71. :loading="payLoading"
  72. @click="submitBtn"
  73. >
  74. 确认
  75. </v-btn>
  76. </div>
  77. </div>
  78. </template>
  79. </v-card>
  80. </template>
  81. <script setup>
  82. defineOptions({ name: 'mall-pay'})
  83. import { computed, onBeforeUnmount, ref } from 'vue'
  84. import QrCode from '@/components/QrCode'
  85. import { definePayTypeList, qrCodePay, walletPay } from '@/utils/payType'
  86. import { getEnableCodeList, getOrderPayStatus } from '@/api/common'
  87. import { getOrder, submitOrder } from '@/api/mall/trade'
  88. const emit = defineEmits(['payTypeChange', 'paySuccess', 'stopInterval', 'getOrderFail', 'payed'])
  89. const props = defineProps({
  90. code: {
  91. type: String,
  92. default: 'mall' // mall:商城付款
  93. },
  94. appId: {
  95. type: Number,
  96. default: 12 // 12:商城付款
  97. },
  98. id: {
  99. type: String,
  100. default: '',
  101. }
  102. })
  103. const loading = ref(true)
  104. const tip = ref('')
  105. // const orderType = ref('goods') // 订单类型; goods - 商品订单, recharge - 充值订单
  106. const orderInfo = ref({})
  107. const payStatus = ref(0) // 0=检测支付环境, -2=未查询到支付单信息, -1=支付已过期, 1=待支付,2=订单已支付
  108. const payMethods = ref([]) // 可选的支付方式
  109. const payment = ref('') // 选中的支付方式
  110. import { useUserStore } from '@/store/user'; const userStore = useUserStore()
  111. const userAccount = ref(JSON.parse(localStorage.getItem('userAccount')) || {}) // 账户信息
  112. userStore.$subscribe((mutation, state) => {
  113. if (Object.keys(state.userAccount).length) userAccount.value = state.userAccount
  114. })
  115. const updateAccountInfo = async (isSnackbar = true) => {
  116. await userStore.getUserAccountBalance()
  117. userAccount.value = JSON.parse(localStorage.getItem('userAccount')) || {}
  118. if (isSnackbar) Snackbar.success('刷新成功!')
  119. }
  120. // 对比余额是否不足 订单金额:orderInfo?.price-0
  121. const balance = computed(() => {
  122. return userAccount.value?.balance ? userAccount.value.balance-0 : 0
  123. })
  124. const notEnoughMoney = computed(() => {
  125. return orderInfo.value?.price-0 > balance.value
  126. })
  127. const Destroyed = ref(false)
  128. onBeforeUnmount(() => {
  129. Destroyed.value = true // 避免执行paySubmit中关闭支付弹窗,造成setInterval一直存在
  130. clear()
  131. })
  132. // 提交支付订单 (提交后倒计时显示及支付状态轮巡)
  133. const timer = ref(null) // 支付状态轮询
  134. const submitOrderFun = async (showLoading = false) => {
  135. if (!payment.value) return
  136. try {
  137. if (orderInfo.value) {
  138. // 提交支付订单
  139. // channelExtras: { openid: null} // 特殊逻辑:微信公众号、小程序支付时,必须传入 openid
  140. const params = {
  141. id: orderInfo.value.id, // 支付单编号
  142. channelCode: payment.value, // 支付渠道
  143. // returnUrl: , // 支付成功后,支付渠道跳转回当前页;再由当前页,跳转回 {@link returnUrl} 对应的地址
  144. }
  145. if (showLoading) loading.value = true
  146. const res = await submitOrder(params)
  147. // 二维码内容赋值
  148. payQrCodeTxt.value = res?.displayContent || ''
  149. remainder.value = 1
  150. //
  151. initIntervalFun() // 倒计时显示及支付状态轮巡
  152. }
  153. } catch (error) {
  154. console.log(error)
  155. } finally {
  156. if (showLoading) loading.value = false
  157. }
  158. }
  159. // 状态转换:payOrder.status => payStatus
  160. const checkPayStatus = () => {
  161. if (orderInfo.value.status === 10 || orderInfo.value.status === 20) {
  162. // 支付成功 (订单已支付)
  163. payStatus.value = 2;
  164. emit('payed')
  165. return
  166. }
  167. if (orderInfo.value.status === 30) {
  168. // 支付关闭
  169. payStatus.value = -1;
  170. return;
  171. }
  172. payStatus.value = 1; // 待支付
  173. }
  174. const countdownInterval = ref(null)
  175. const countdownIntervalInit = () => {
  176. countdownInterval.value = setInterval(() => {
  177. countdown.value
  178. }, 1000)
  179. }
  180. // 获得支付订单信息
  181. const setOrder = async () => {
  182. if (!props.id-0) return emit('getOrderFail')
  183. try {
  184. const data = await getOrder(props.id, true) // 获取待支付的订单 (order:业务订单; orderInfo:支付订单)
  185. if (!data) {
  186. payStatus.value = -2;
  187. return
  188. }
  189. orderInfo.value = data || null
  190. // 剩余支付时间倒计时
  191. countdownIntervalInit()
  192. // 设置支付状态
  193. checkPayStatus()
  194. // await updateAccountInfo()
  195. // 获得支付方式
  196. await setPayMethods()
  197. // 获得支付方式
  198. // await setPayMethods();
  199. if (isQrCodePay.value) submitOrderFun() // 二维码支付时自动创建订单获取二维码内容展示
  200. } catch (error) {
  201. console.log('error:', error)
  202. } finally {
  203. loading.value = false
  204. }
  205. }
  206. setOrder()
  207. // 清空setInterval
  208. function clear() {
  209. remainder.value = 1
  210. payQrCodeTxt.value = '' // 二维码内容
  211. if (countdownInterval.value) clearInterval(countdownInterval.value); countdownInterval.value = null
  212. if (timer.value) clearInterval(timer.value); timer.value = null
  213. if (remainderTimer.value) clearInterval(remainderTimer.value); remainderTimer.value = null
  214. }
  215. // 1.获得支付方式
  216. const isWalletPay = ref(false)
  217. const isQrCodePay = ref(false)
  218. const payTypeChange = (value) => {
  219. payment.value = value
  220. tip.value = payMethods.value.find(e => e.code === payment.value)?.tip || ''
  221. isQrCodePay.value = qrCodePay.includes(payment.value)
  222. isWalletPay.value = walletPay.includes(payment.value)
  223. if (isQrCodePay.value) submitOrderFun() // 二维码支付时自动创建订单获取二维码内容展示
  224. else { clear() }
  225. }
  226. // 1.支付方式
  227. const setPayMethods = async () => {
  228. let list = []
  229. payMethods.value = []
  230. try {
  231. // list = await getEnableCodeList2(props.code)
  232. list = await getEnableCodeList({ appId: props.appId })
  233. } catch (error) {
  234. console.log(error)
  235. } finally {
  236. if (definePayTypeList?.length && list?.length) {
  237. list.forEach(code => {
  238. const item = definePayTypeList.find(p => p.code === code)
  239. if (item) {
  240. if (!payment.value) {
  241. tip.value = item.tip || ''
  242. payTypeChange(code) // 默认值赋值
  243. }
  244. payMethods.value.push(item)
  245. }
  246. })
  247. }
  248. }
  249. }
  250. const payLoading = ref(false)
  251. const payQrCodeTxt = ref('')
  252. // 钱包支付(余额支付)、模拟支付
  253. const submitBtn = () => {
  254. payLoading.value = true
  255. submitOrderFun()
  256. }
  257. import Snackbar from '@/plugins/snackbar'
  258. import { useRoute } from 'vue-router'; const route = useRoute()
  259. import { useRouter } from 'vue-router'; const router = useRouter()
  260. const getPayStatus = async () => {
  261. if (Destroyed.value) return clear() // 用户关闭支付
  262. try {
  263. const data = await getOrderPayStatus({ id: orderInfo.value.id || orderInfo.value.payOrderId })
  264. if ((data?.status - 0) === 10) {
  265. // 支付成功
  266. clear()
  267. setTimeout(() => {
  268. emit('paySuccess', { price: orderInfo.value.price })
  269. if (isWalletPay.value) updateAccountInfo() // 更新余额
  270. // Snackbar.success('支付成功')
  271. if (route.fullPath === props.returnUrl) router.go(0) // 刷新页面
  272. else if (props.returnUrl) router.push(props.returnUrl) // 返回指定页面
  273. }, 2000);
  274. }
  275. } catch (error) {
  276. console.log(error)
  277. }
  278. }
  279. // 倒计时
  280. const countdownTime = 60000 * 3 // 倒计时三分钟
  281. const remainder = ref(1) // number 初始化不能为假,否则不能显示二维码
  282. const remainderZhShow = ref('') // 倒计时展示
  283. const remainderTimer = ref(null)
  284. // 初始化倒计时
  285. const initIntervalFun = async () => {
  286. remainder.value = countdownTime // 初始倒计时时间
  287. if (remainderTimer.value) clearInterval(remainderTimer.value); remainderTimer.value = null // 每一次点击都清除上一个轮询
  288. if (timer.value) clearInterval(timer.value); timer.value = null
  289. //
  290. await getPayStatus() // 立即查询一次支付状态
  291. remainderCalc(); remainderTimer.value = setInterval(() => { remainderCalc() }, 1000) // 倒计时计算
  292. timer.value = setInterval(() => { getPayStatus() }, 2000) // 轮巡支付状态
  293. }
  294. const remainderCalc = () => {
  295. if (Destroyed.value) return clear() // 用户关闭支付
  296. remainder.value -= 1000
  297. remainderZhShow.value = formatDuration(remainder.value)
  298. if (remainder.value <= 0) { // 倒计时结束
  299. tip.value = ''
  300. if (timer.value) clearInterval(timer.value); timer.value = null
  301. emit('stopInterval') // 倒计时结束,关闭倒计时弹窗
  302. }
  303. }
  304. const formatDuration = (remainder) => {
  305. // 将毫秒转换为秒
  306. var seconds = Math.floor(remainder / 1000)
  307. // 计算分钟和剩余的秒数
  308. var minutes = Math.floor(seconds / 60)
  309. var remainingSeconds = seconds % 60
  310. // 格式化分钟和秒数,确保秒数为两位数(如果小于10,则前面补0)
  311. minutes = minutes.toString().padStart(2, '0')
  312. remainingSeconds = remainingSeconds.toString().padStart(2, '0')
  313. // 返回格式化的字符串
  314. return `${minutes}分${remainingSeconds}秒`
  315. }
  316. // 支付倒计时文案提示
  317. const countdown = computed(() => {
  318. const now = Date.now();
  319. const distance = orderInfo.value.expireTime - now
  320. if (distance < 0) {
  321. clearInterval(countdownInterval.value)
  322. router.go(0)
  323. return '已过期'
  324. }
  325. const seconds = Math.floor((distance / 1000) % 60)
  326. const minutes = Math.floor((distance / (1000 * 60)) % 60)
  327. const hours = Math.floor((distance / (1000 * 60 * 60)) % 24)
  328. // const days = Math.floor(distance / (1000 * 60 * 60 * 24))
  329. return '剩余支付时间: ' + hours + ':' + minutes + ':' + seconds
  330. });
  331. const open = () => {
  332. window.open('/personalRecharge')
  333. }
  334. </script>
  335. <style lang="scss" scoped>
  336. .font-size-40 { font-size: 40px; }
  337. </style>