小说网站前端代码仓库
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.

693 lines
22 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. >
  11. <div class="payment-content">
  12. <!-- 支付信息 -->
  13. <div class="payment-info">
  14. <div class="payment-title">{{ paymentData.title }}</div>
  15. <div class="payment-amount">
  16. ¥<span class="amount-value">{{ formatAmount(paymentData.amount) }}</span>
  17. </div>
  18. </div>
  19. <!-- 支付状态 -->
  20. <div class="payment-status">
  21. <template v-if="paymentStatus === 'pending'">
  22. <div class="qr-container">
  23. <div class="qr-code" ref="qrCodeRef">
  24. <!-- 二维码将在这里生成 -->
  25. <div style="color: #999; font-size: 12px;">正在加载支付二维码...</div>
  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, wechatPayApi } from '@/api/user';
  100. import {
  101. Iphone,
  102. Loading,
  103. CircleCheck,
  104. CircleClose,
  105. Clock
  106. } from '@element-plus/icons-vue';
  107. import QRCode from 'qrcode';
  108. export default {
  109. name: 'WechatPayment',
  110. components: {
  111. Iphone,
  112. Loading,
  113. CircleCheck,
  114. CircleClose,
  115. Clock
  116. },
  117. props: {
  118. // 是否显示支付弹窗
  119. modelValue: {
  120. type: Boolean,
  121. default: false
  122. },
  123. // 支付数据
  124. paymentData: {
  125. type: Object,
  126. default: () => ({
  127. })
  128. }
  129. },
  130. emits: ['update:modelValue', 'success', 'cancel', 'failed'],
  131. setup(props, { emit }) {
  132. const visible = computed({
  133. get: () => props.modelValue,
  134. set: (value) => emit('update:modelValue', value)
  135. });
  136. const qrCodeRef = ref(null);
  137. const paymentStatus = ref('loading');
  138. const countdown = ref(300);
  139. const refreshing = ref(false);
  140. const errorMessage = ref('');
  141. let pollingTimer = null;
  142. let countdownTimer = null;
  143. let outTradeNo = '';
  144. let orderId = '';
  145. // 格式化金额
  146. const formatAmount = (amount) => {
  147. return (amount / 100).toFixed(2);
  148. };
  149. // 格式化时间
  150. const formatTime = (seconds) => {
  151. const minutes = Math.floor(seconds / 60);
  152. const remainingSeconds = seconds % 60;
  153. return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  154. };
  155. // 生成二维码
  156. const generateQrCode = async (codeUrl) => {
  157. try {
  158. console.log('开始生成二维码');
  159. // 等待DOM更新
  160. await nextTick();
  161. // 多次检查DOM元素是否存在
  162. let retryCount = 0;
  163. while (!qrCodeRef.value && retryCount < 20) {
  164. await new Promise(resolve => setTimeout(resolve, 100));
  165. retryCount++;
  166. await nextTick();
  167. }
  168. if (!qrCodeRef.value) {
  169. throw new Error('二维码容器元素未找到');
  170. }
  171. // 清空容器
  172. qrCodeRef.value.innerHTML = '';
  173. // 生成二维码
  174. const canvas = document.createElement('canvas');
  175. await QRCode.toCanvas(canvas, codeUrl, {
  176. width: 200,
  177. height: 200,
  178. margin: 1,
  179. color: {
  180. dark: '#000000',
  181. light: '#FFFFFF'
  182. }
  183. });
  184. // 将canvas添加到容器中
  185. qrCodeRef.value.appendChild(canvas);
  186. console.log('二维码生成成功');
  187. } catch (error) {
  188. console.error('生成二维码失败:', error);
  189. ElMessage.error(`生成二维码失败: ${error.message}`);
  190. // 如果二维码生成失败,显示错误信息
  191. if (qrCodeRef.value) {
  192. qrCodeRef.value.innerHTML = `
  193. <div style="width: 200px; height: 200px; border: 2px dashed #ccc; display: flex; align-items: center; justify-content: center; color: #999; font-size: 14px; text-align: center;">
  194. 二维码生成失败<br/>请点击刷新重试
  195. </div>
  196. `;
  197. } else {
  198. // 如果容器都找不到,强制设置状态显示错误
  199. paymentStatus.value = 'failed';
  200. errorMessage.value = '二维码容器未找到,请刷新页面重试';
  201. }
  202. }
  203. };
  204. // 创建支付订单
  205. const createPaymentOrder = async () => {
  206. try {
  207. paymentStatus.value = 'loading';
  208. if (!props.paymentData.packageId) {
  209. throw new Error('缺少套餐ID');
  210. }
  211. const params = {
  212. packageId: props.paymentData.packageId,
  213. payType: 'web'
  214. };
  215. // 创建订单
  216. const result = await orderApi.createPayPackageOrder(params);
  217. if (result.success && result.result) {
  218. const { codeURL, orderId: responseOrderId } = result.result;
  219. orderId = responseOrderId;
  220. outTradeNo = responseOrderId; // 使用orderId作为outTradeNo
  221. // 先切换状态为pending,让DOM元素渲染出来
  222. paymentStatus.value = 'pending';
  223. // 等待DOM更新完成后再生成二维码
  224. await nextTick();
  225. await new Promise(resolve => setTimeout(resolve, 100)); // 额外等待100ms确保DOM渲染完成
  226. await generateQrCode(codeURL);
  227. startPolling();
  228. startCountdown();
  229. } else {
  230. throw new Error(result.message || '创建支付订单失败');
  231. }
  232. } catch (error) {
  233. console.error('创建支付订单失败:', error);
  234. paymentStatus.value = 'failed';
  235. errorMessage.value = error.message;
  236. ElMessage.error(error.message || '创建支付订单失败');
  237. }
  238. };
  239. // 开始轮询支付状态
  240. const startPolling = () => {
  241. if (pollingTimer) {
  242. clearInterval(pollingTimer);
  243. }
  244. pollingTimer = setInterval(async () => {
  245. await checkPaymentStatus();
  246. }, 2000);
  247. };
  248. // 停止轮询
  249. const stopPolling = () => {
  250. if (pollingTimer) {
  251. clearInterval(pollingTimer);
  252. pollingTimer = null;
  253. }
  254. };
  255. // 检查支付状态
  256. const checkPaymentStatus = async () => {
  257. try {
  258. if (!outTradeNo) {
  259. return;
  260. }
  261. // 调用微信支付状态查询接口
  262. const response = await wechatPayApi.queryWechatPayStatus(outTradeNo);
  263. if (response.success) {
  264. const paymentState = response.result;
  265. console.log('支付状态查询结果:', paymentState);
  266. switch (paymentState) {
  267. case 'SUCCESS':
  268. // 支付成功
  269. handlePaymentSuccess();
  270. break;
  271. case 'CLOSED':
  272. case 'REVOKED':
  273. case 'PAYERROR':
  274. // 支付失败的状态
  275. handlePaymentFailed(getPaymentStateMessage(paymentState));
  276. break;
  277. case 'REFUND':
  278. // 已退款
  279. handlePaymentFailed('支付已退款');
  280. break;
  281. case 'NOTPAY':
  282. case 'USERPAYING':
  283. // 未支付或用户支付中,继续轮询
  284. console.log('支付状态:', getPaymentStateMessage(paymentState));
  285. break;
  286. default:
  287. console.log('未知支付状态:', paymentState);
  288. break;
  289. }
  290. }
  291. } catch (error) {
  292. console.error('查询支付状态失败:', error);
  293. // 查询失败不影响轮询继续
  294. }
  295. };
  296. // 获取支付状态的中文描述
  297. const getPaymentStateMessage = (state) => {
  298. const stateMap = {
  299. 'SUCCESS': '支付成功',
  300. 'REFUND': '转入退款',
  301. 'NOTPAY': '未支付',
  302. 'CLOSED': '已关闭',
  303. 'REVOKED': '已撤销',
  304. 'USERPAYING': '用户支付中',
  305. 'PAYERROR': '支付失败'
  306. };
  307. return stateMap[state] || `未知状态: ${state}`;
  308. };
  309. // 处理支付成功
  310. const handlePaymentSuccess = () => {
  311. paymentStatus.value = 'success';
  312. stopPolling();
  313. stopCountdown();
  314. ElMessage.success('支付成功!');
  315. emit('success', orderId);
  316. setTimeout(() => {
  317. visible.value = false;
  318. }, 3000);
  319. };
  320. // 处理支付失败
  321. const handlePaymentFailed = (message) => {
  322. paymentStatus.value = 'failed';
  323. errorMessage.value = message;
  324. stopPolling();
  325. stopCountdown();
  326. ElMessage.error(message || '支付失败');
  327. emit('failed', message);
  328. };
  329. // 开始倒计时
  330. const startCountdown = () => {
  331. countdown.value = 300;
  332. countdownTimer = setInterval(() => {
  333. countdown.value--;
  334. if (countdown.value <= 0) {
  335. handlePaymentTimeout();
  336. }
  337. }, 1000);
  338. };
  339. // 停止倒计时
  340. const stopCountdown = () => {
  341. if (countdownTimer) {
  342. clearInterval(countdownTimer);
  343. countdownTimer = null;
  344. }
  345. };
  346. // 处理支付超时
  347. const handlePaymentTimeout = () => {
  348. paymentStatus.value = 'timeout';
  349. stopPolling();
  350. stopCountdown();
  351. ElMessage.warning('支付超时,请重新发起支付');
  352. };
  353. // 刷新二维码
  354. const refreshQrCode = async () => {
  355. refreshing.value = true;
  356. try {
  357. await createPaymentOrder();
  358. } finally {
  359. refreshing.value = false;
  360. }
  361. };
  362. // 重新支付
  363. const retryPayment = () => {
  364. errorMessage.value = '';
  365. createPaymentOrder();
  366. };
  367. // 取消支付
  368. const handleCancel = async () => {
  369. if (paymentStatus.value === 'success') {
  370. visible.value = false;
  371. return;
  372. }
  373. try {
  374. await ElMessageBox.confirm(
  375. '确定要取消支付吗?',
  376. '取消支付',
  377. {
  378. confirmButtonText: '确定',
  379. cancelButtonText: '继续支付',
  380. type: 'warning',
  381. }
  382. );
  383. stopPolling();
  384. stopCountdown();
  385. visible.value = false;
  386. emit('cancel');
  387. } catch {
  388. // 用户选择继续支付
  389. }
  390. };
  391. // 监听弹窗显示状态
  392. watch(visible, (newVal) => {
  393. if (newVal && props.paymentData.packageId) {
  394. createPaymentOrder();
  395. } else {
  396. stopPolling();
  397. stopCountdown();
  398. paymentStatus.value = 'loading';
  399. errorMessage.value = '';
  400. outTradeNo = '';
  401. orderId = '';
  402. }
  403. });
  404. // 组件卸载时清理
  405. onUnmounted(() => {
  406. stopPolling();
  407. stopCountdown();
  408. });
  409. return {
  410. visible,
  411. qrCodeRef,
  412. paymentStatus,
  413. countdown,
  414. refreshing,
  415. errorMessage,
  416. formatAmount,
  417. formatTime,
  418. refreshQrCode,
  419. retryPayment,
  420. handleCancel
  421. };
  422. }
  423. };
  424. </script>
  425. <style lang="scss" scoped>
  426. .wechat-payment {
  427. .payment-content {
  428. text-align: center;
  429. padding: 20px 0;
  430. .payment-info {
  431. margin-bottom: 20px;
  432. .payment-title {
  433. font-size: 18px;
  434. font-weight: 600;
  435. color: #303133;
  436. margin-bottom: 8px;
  437. }
  438. .payment-amount {
  439. font-size: 24px;
  440. color: #0A2463;
  441. font-weight: 700;
  442. .amount-value {
  443. font-size: 32px;
  444. }
  445. }
  446. }
  447. .payment-status {
  448. min-height: 300px;
  449. display: flex;
  450. flex-direction: column;
  451. justify-content: center;
  452. align-items: center;
  453. .qr-container {
  454. .qr-code {
  455. margin-bottom: 16px;
  456. display: flex;
  457. justify-content: center;
  458. align-items: center;
  459. min-height: 200px;
  460. min-width: 200px;
  461. :deep(canvas) {
  462. border-radius: 8px;
  463. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  464. max-width: 100%;
  465. height: auto;
  466. }
  467. // 确保容器有背景,方便调试
  468. border: 1px solid #eee;
  469. border-radius: 8px;
  470. }
  471. .qr-tips {
  472. .scan-icon {
  473. font-size: 32px;
  474. color: #0A2463;
  475. margin-bottom: 8px;
  476. }
  477. p {
  478. margin: 4px 0;
  479. color: #606266;
  480. &:first-of-type {
  481. font-size: 16px;
  482. font-weight: 600;
  483. color: #303133;
  484. }
  485. }
  486. .tips-text {
  487. font-size: 14px;
  488. color: #909399;
  489. }
  490. }
  491. }
  492. .countdown {
  493. margin-top: 16px;
  494. font-size: 14px;
  495. color: #909399;
  496. .countdown-time {
  497. color: #F56C6C;
  498. font-weight: 600;
  499. }
  500. }
  501. .loading-status,
  502. .success-status,
  503. .failed-status,
  504. .timeout-status {
  505. display: flex;
  506. flex-direction: column;
  507. align-items: center;
  508. .loading-icon {
  509. font-size: 48px;
  510. color: #0A2463;
  511. animation: rotate 2s linear infinite;
  512. margin-bottom: 16px;
  513. }
  514. .success-icon {
  515. font-size: 48px;
  516. color: #67C23A;
  517. margin-bottom: 16px;
  518. }
  519. .failed-icon,
  520. .timeout-icon {
  521. font-size: 48px;
  522. color: #F56C6C;
  523. margin-bottom: 16px;
  524. }
  525. p {
  526. margin: 4px 0;
  527. font-size: 16px;
  528. color: #303133;
  529. &:first-of-type {
  530. font-weight: 600;
  531. font-size: 18px;
  532. }
  533. }
  534. .success-tips,
  535. .failed-tips,
  536. .timeout-tips {
  537. font-size: 14px;
  538. color: #909399;
  539. }
  540. }
  541. }
  542. }
  543. .dialog-footer {
  544. display: flex;
  545. justify-content: center;
  546. gap: 12px;
  547. .el-button {
  548. &.el-button--primary {
  549. background-color: #0A2463;
  550. border-color: #0A2463;
  551. &:hover {
  552. background-color: #083354;
  553. border-color: #083354;
  554. }
  555. }
  556. }
  557. }
  558. }
  559. @keyframes rotate {
  560. from {
  561. transform: rotate(0deg);
  562. }
  563. to {
  564. transform: rotate(360deg);
  565. }
  566. }
  567. // 响应式设计
  568. @media (max-width: 768px) {
  569. .wechat-payment {
  570. :deep(.el-dialog) {
  571. width: 90% !important;
  572. margin: 5vh auto !important;
  573. }
  574. .payment-content {
  575. padding: 16px 0;
  576. .payment-info {
  577. .payment-title {
  578. font-size: 16px;
  579. }
  580. .payment-amount {
  581. font-size: 20px;
  582. .amount-value {
  583. font-size: 28px;
  584. }
  585. }
  586. }
  587. .payment-status {
  588. min-height: 250px;
  589. .qr-container {
  590. .qr-code {
  591. :deep(canvas) {
  592. width: 160px !important;
  593. height: 160px !important;
  594. }
  595. }
  596. }
  597. }
  598. }
  599. }
  600. }
  601. </style>