小说网站前端代码仓库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

603 lines
18 KiB

  1. <template>
  2. <div class="wechat-payment">
  3. <!-- 支付弹窗 -->
  4. <el-dialog
  5. v-model="visible"
  6. title="微信支付"
  7. width="400px"
  8. :close-on-click-modal="false"
  9. :close-on-press-escape="false"
  10. @close="handleCancel"
  11. >
  12. <div class="payment-content">
  13. <!-- 支付信息 -->
  14. <div class="payment-info">
  15. <div class="payment-title">{{ paymentData.title }}</div>
  16. <div class="payment-amount">
  17. ¥<span class="amount-value">{{ formatAmount(paymentData.amount) }}</span>
  18. </div>
  19. </div>
  20. <!-- 支付状态 -->
  21. <div class="payment-status">
  22. <template v-if="paymentStatus === 'pending'">
  23. <div class="qr-container">
  24. <div class="qr-code" ref="qrCodeRef">
  25. <!-- 二维码将在这里生成 -->
  26. </div>
  27. <div class="qr-tips">
  28. <el-icon class="scan-icon"><Iphone /></el-icon>
  29. <p>请使用微信扫码支付</p>
  30. <p class="tips-text">扫码后请在手机上完成支付</p>
  31. </div>
  32. </div>
  33. <!-- 倒计时 -->
  34. <div class="countdown">
  35. <span>支付剩余时间</span>
  36. <span class="countdown-time">{{ formatTime(countdown) }}</span>
  37. </div>
  38. </template>
  39. <template v-else-if="paymentStatus === 'loading'">
  40. <div class="loading-status">
  41. <el-icon class="loading-icon"><Loading /></el-icon>
  42. <p>正在生成支付二维码...</p>
  43. </div>
  44. </template>
  45. <template v-else-if="paymentStatus === 'success'">
  46. <div class="success-status">
  47. <el-icon class="success-icon"><CircleCheck /></el-icon>
  48. <p>支付成功</p>
  49. <p class="success-tips">感谢您的支付页面即将跳转...</p>
  50. </div>
  51. </template>
  52. <template v-else-if="paymentStatus === 'failed'">
  53. <div class="failed-status">
  54. <el-icon class="failed-icon"><CircleClose /></el-icon>
  55. <p>支付失败</p>
  56. <p class="failed-tips">{{ errorMessage || '支付过程中出现异常,请重试' }}</p>
  57. </div>
  58. </template>
  59. <template v-else-if="paymentStatus === 'timeout'">
  60. <div class="timeout-status">
  61. <el-icon class="timeout-icon"><Clock /></el-icon>
  62. <p>支付超时</p>
  63. <p class="timeout-tips">二维码已过期请重新发起支付</p>
  64. </div>
  65. </template>
  66. </div>
  67. </div>
  68. <!-- 操作按钮 -->
  69. <template #footer>
  70. <div class="dialog-footer">
  71. <el-button
  72. v-if="paymentStatus === 'pending'"
  73. @click="refreshQrCode"
  74. :loading="refreshing"
  75. >
  76. 刷新二维码
  77. </el-button>
  78. <el-button
  79. v-if="paymentStatus === 'failed' || paymentStatus === 'timeout'"
  80. type="primary"
  81. @click="retryPayment"
  82. >
  83. 重新支付
  84. </el-button>
  85. <el-button
  86. @click="handleCancel"
  87. :disabled="paymentStatus === 'loading'"
  88. >
  89. {{ paymentStatus === 'success' ? '关闭' : '取消支付' }}
  90. </el-button>
  91. </div>
  92. </template>
  93. </el-dialog>
  94. </div>
  95. </template>
  96. <script>
  97. import { ref, computed, nextTick, onUnmounted, watch } from 'vue';
  98. import { ElMessage, ElMessageBox } from 'element-plus';
  99. import { orderApi } from '@/api/user';
  100. import {
  101. Iphone,
  102. Loading,
  103. CircleCheck,
  104. CircleClose,
  105. Clock
  106. } from '@element-plus/icons-vue';
  107. import { wechatPayApi } from '@/api/user.js';
  108. import QRCode from 'qrcode';
  109. export default {
  110. name: 'WechatPayment',
  111. components: {
  112. Iphone,
  113. Loading,
  114. CircleCheck,
  115. CircleClose,
  116. Clock
  117. },
  118. props: {
  119. // 是否显示支付弹窗
  120. modelValue: {
  121. type: Boolean,
  122. default: false
  123. },
  124. // 支付数据
  125. paymentData: {
  126. type: Object,
  127. default: () => ({
  128. orderId: '', // 订单ID
  129. title: '商品支付', // 支付标题
  130. amount: 0, // 支付金额(分)
  131. body: '' // 商品描述
  132. })
  133. }
  134. },
  135. emits: ['update:modelValue', 'success', 'cancel', 'failed'],
  136. setup(props, { emit }) {
  137. const visible = computed({
  138. get: () => props.modelValue,
  139. set: (value) => emit('update:modelValue', value)
  140. });
  141. const qrCodeRef = ref(null);
  142. const paymentStatus = ref('loading');
  143. const countdown = ref(300);
  144. const refreshing = ref(false);
  145. const errorMessage = ref('');
  146. let pollingTimer = null;
  147. let countdownTimer = null;
  148. let outTradeNo = '';
  149. // 格式化金额
  150. const formatAmount = (amount) => {
  151. return (amount / 100).toFixed(2);
  152. };
  153. // 格式化时间
  154. const formatTime = (seconds) => {
  155. const minutes = Math.floor(seconds / 60);
  156. const remainingSeconds = seconds % 60;
  157. return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  158. };
  159. // 生成二维码
  160. const generateQrCode = async (codeUrl) => {
  161. try {
  162. await nextTick();
  163. if (qrCodeRef.value) {
  164. qrCodeRef.value.innerHTML = '';
  165. await QRCode.toCanvas(qrCodeRef.value, codeUrl, {
  166. width: 200,
  167. height: 200,
  168. margin: 1,
  169. color: {
  170. dark: '#000000',
  171. light: '#FFFFFF'
  172. }
  173. });
  174. }
  175. } catch (error) {
  176. console.error('生成二维码失败:', error);
  177. ElMessage.error('生成二维码失败');
  178. }
  179. };
  180. // 创建支付订单
  181. const createPaymentOrder = async () => {
  182. try {
  183. paymentStatus.value = 'loading';
  184. const params = {
  185. orderId: props.paymentData.orderId,
  186. totalFee: props.paymentData.amount,
  187. body: props.paymentData.body || props.paymentData.title
  188. };
  189. // 模拟API响应
  190. await new Promise(resolve => setTimeout(resolve, 1000));
  191. const mockResponse = {
  192. code: 200,
  193. result: {
  194. codeUrl: `weixin://wxpay/bizpayurl?pr=${Math.random().toString(36).substr(2, 9)}`,
  195. outTradeNo: `ORDER_${Date.now()}`
  196. }
  197. };
  198. if (mockResponse.code === 200 && mockResponse.result) {
  199. const { codeUrl, outTradeNo: tradeNo } = mockResponse.result;
  200. outTradeNo = tradeNo;
  201. await generateQrCode(codeUrl);
  202. paymentStatus.value = 'pending';
  203. startPolling();
  204. startCountdown();
  205. } else {
  206. throw new Error('创建支付订单失败');
  207. }
  208. } catch (error) {
  209. console.error('创建支付订单失败:', error);
  210. paymentStatus.value = 'failed';
  211. errorMessage.value = error.message;
  212. ElMessage.error(error.message || '创建支付订单失败');
  213. }
  214. };
  215. // 开始轮询支付状态
  216. const startPolling = () => {
  217. if (pollingTimer) {
  218. clearInterval(pollingTimer);
  219. }
  220. pollingTimer = setInterval(async () => {
  221. await checkPaymentStatus();
  222. }, 2000);
  223. };
  224. // 停止轮询
  225. const stopPolling = () => {
  226. if (pollingTimer) {
  227. clearInterval(pollingTimer);
  228. pollingTimer = null;
  229. }
  230. };
  231. // 检查支付状态
  232. const checkPaymentStatus = async () => {
  233. try {
  234. // 模拟随机支付成功(演示用)
  235. if (Math.random() > 0.95) {
  236. handlePaymentSuccess();
  237. return;
  238. }
  239. // 实际项目中调用真实API
  240. // const response = await wechatPayApi.queryWechatPayStatus(outTradeNo);
  241. } catch (error) {
  242. console.error('查询支付状态失败:', error);
  243. }
  244. };
  245. // 处理支付成功
  246. const handlePaymentSuccess = () => {
  247. paymentStatus.value = 'success';
  248. stopPolling();
  249. stopCountdown();
  250. ElMessage.success('支付成功!');
  251. emit('success', outTradeNo);
  252. setTimeout(() => {
  253. visible.value = false;
  254. }, 3000);
  255. };
  256. // 处理支付失败
  257. const handlePaymentFailed = (message) => {
  258. paymentStatus.value = 'failed';
  259. errorMessage.value = message;
  260. stopPolling();
  261. stopCountdown();
  262. ElMessage.error(message || '支付失败');
  263. emit('failed', message);
  264. };
  265. // 开始倒计时
  266. const startCountdown = () => {
  267. countdown.value = 300;
  268. countdownTimer = setInterval(() => {
  269. countdown.value--;
  270. if (countdown.value <= 0) {
  271. handlePaymentTimeout();
  272. }
  273. }, 1000);
  274. };
  275. // 停止倒计时
  276. const stopCountdown = () => {
  277. if (countdownTimer) {
  278. clearInterval(countdownTimer);
  279. countdownTimer = null;
  280. }
  281. };
  282. // 处理支付超时
  283. const handlePaymentTimeout = () => {
  284. paymentStatus.value = 'timeout';
  285. stopPolling();
  286. stopCountdown();
  287. ElMessage.warning('支付超时,请重新发起支付');
  288. };
  289. // 刷新二维码
  290. const refreshQrCode = async () => {
  291. refreshing.value = true;
  292. try {
  293. await createPaymentOrder();
  294. } finally {
  295. refreshing.value = false;
  296. }
  297. };
  298. // 重新支付
  299. const retryPayment = () => {
  300. errorMessage.value = '';
  301. createPaymentOrder();
  302. };
  303. // 取消支付
  304. const handleCancel = async () => {
  305. if (paymentStatus.value === 'success') {
  306. visible.value = false;
  307. return;
  308. }
  309. try {
  310. await ElMessageBox.confirm(
  311. '确定要取消支付吗?',
  312. '取消支付',
  313. {
  314. confirmButtonText: '确定',
  315. cancelButtonText: '继续支付',
  316. type: 'warning',
  317. }
  318. );
  319. stopPolling();
  320. stopCountdown();
  321. visible.value = false;
  322. emit('cancel');
  323. } catch {
  324. // 用户选择继续支付
  325. }
  326. };
  327. // 监听弹窗显示状态
  328. watch(visible, (newVal) => {
  329. if (newVal && props.paymentData.orderId) {
  330. createPaymentOrder();
  331. } else {
  332. stopPolling();
  333. stopCountdown();
  334. paymentStatus.value = 'loading';
  335. errorMessage.value = '';
  336. outTradeNo = '';
  337. }
  338. });
  339. // 组件卸载时清理
  340. onUnmounted(() => {
  341. stopPolling();
  342. stopCountdown();
  343. });
  344. return {
  345. visible,
  346. qrCodeRef,
  347. paymentStatus,
  348. countdown,
  349. refreshing,
  350. errorMessage,
  351. formatAmount,
  352. formatTime,
  353. refreshQrCode,
  354. retryPayment,
  355. handleCancel
  356. };
  357. }
  358. };
  359. </script>
  360. <style lang="scss" scoped>
  361. .wechat-payment {
  362. .payment-content {
  363. text-align: center;
  364. padding: 20px 0;
  365. .payment-info {
  366. margin-bottom: 20px;
  367. .payment-title {
  368. font-size: 18px;
  369. font-weight: 600;
  370. color: #303133;
  371. margin-bottom: 8px;
  372. }
  373. .payment-amount {
  374. font-size: 24px;
  375. color: #0A2463;
  376. font-weight: 700;
  377. .amount-value {
  378. font-size: 32px;
  379. }
  380. }
  381. }
  382. .payment-status {
  383. min-height: 300px;
  384. display: flex;
  385. flex-direction: column;
  386. justify-content: center;
  387. align-items: center;
  388. .qr-container {
  389. .qr-code {
  390. margin-bottom: 16px;
  391. display: flex;
  392. justify-content: center;
  393. :deep(canvas) {
  394. border-radius: 8px;
  395. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  396. }
  397. }
  398. .qr-tips {
  399. .scan-icon {
  400. font-size: 32px;
  401. color: #0A2463;
  402. margin-bottom: 8px;
  403. }
  404. p {
  405. margin: 4px 0;
  406. color: #606266;
  407. &:first-of-type {
  408. font-size: 16px;
  409. font-weight: 600;
  410. color: #303133;
  411. }
  412. }
  413. .tips-text {
  414. font-size: 14px;
  415. color: #909399;
  416. }
  417. }
  418. }
  419. .countdown {
  420. margin-top: 16px;
  421. font-size: 14px;
  422. color: #909399;
  423. .countdown-time {
  424. color: #F56C6C;
  425. font-weight: 600;
  426. }
  427. }
  428. .loading-status,
  429. .success-status,
  430. .failed-status,
  431. .timeout-status {
  432. display: flex;
  433. flex-direction: column;
  434. align-items: center;
  435. .loading-icon {
  436. font-size: 48px;
  437. color: #0A2463;
  438. animation: rotate 2s linear infinite;
  439. margin-bottom: 16px;
  440. }
  441. .success-icon {
  442. font-size: 48px;
  443. color: #67C23A;
  444. margin-bottom: 16px;
  445. }
  446. .failed-icon,
  447. .timeout-icon {
  448. font-size: 48px;
  449. color: #F56C6C;
  450. margin-bottom: 16px;
  451. }
  452. p {
  453. margin: 4px 0;
  454. font-size: 16px;
  455. color: #303133;
  456. &:first-of-type {
  457. font-weight: 600;
  458. font-size: 18px;
  459. }
  460. }
  461. .success-tips,
  462. .failed-tips,
  463. .timeout-tips {
  464. font-size: 14px;
  465. color: #909399;
  466. }
  467. }
  468. }
  469. }
  470. .dialog-footer {
  471. display: flex;
  472. justify-content: center;
  473. gap: 12px;
  474. .el-button {
  475. &.el-button--primary {
  476. background-color: #0A2463;
  477. border-color: #0A2463;
  478. &:hover {
  479. background-color: #083354;
  480. border-color: #083354;
  481. }
  482. }
  483. }
  484. }
  485. }
  486. @keyframes rotate {
  487. from {
  488. transform: rotate(0deg);
  489. }
  490. to {
  491. transform: rotate(360deg);
  492. }
  493. }
  494. // 响应式设计
  495. @media (max-width: 768px) {
  496. .wechat-payment {
  497. :deep(.el-dialog) {
  498. width: 90% !important;
  499. margin: 5vh auto !important;
  500. }
  501. .payment-content {
  502. padding: 16px 0;
  503. .payment-info {
  504. .payment-title {
  505. font-size: 16px;
  506. }
  507. .payment-amount {
  508. font-size: 20px;
  509. .amount-value {
  510. font-size: 28px;
  511. }
  512. }
  513. }
  514. .payment-status {
  515. min-height: 250px;
  516. .qr-container {
  517. .qr-code {
  518. :deep(canvas) {
  519. width: 160px !important;
  520. height: 160px !important;
  521. }
  522. }
  523. }
  524. }
  525. }
  526. }
  527. }
  528. </style>