前端Stripe跨境支付对接感想

项目背景

新入职的公司是做海外平台的类似于商城的一个app,里面涉及到支付,我写的是web端支付,现有的支付用的都是stripe调用,所以写下这篇笔记,未来如果再用到也算是有些思路

使用支付方式

我使用的支付方式是自己创建支付样式,而不是直接调用stripe平台的内置样式。Ps:我也试过stripe平台的内置支付组件可惜没搞懂,相关文档太少了,官方文档也大部分是英文的💔

支付逻辑

graph TD 用户选择服务完毕后点击前端按钮调起支付 --> 前端调用后端获取支付信息接口 --> 后端返回支付相关信息例如publishableKey --> 前端获取到支付信息调用stripeAPI --> 根据stripe返回的信息判断用户是否支付成功做后续处理

支付组件

这个就是我的stripe支付组件

js 复制代码
<template>
  <div class="payment-container">
    <h2>确认支付</h2>
    
    <!-- 订单信息(由父组件传入) -->
    <div class="order-info" v-if="orderNo || amount">
      <p v-if="orderNo">{{ t('payment.orderNo') }} {{ orderNo }}</p>
      <p v-if="amount">{{ t('payment.amount') }}{{ amount }} {{ currencySymbol }}</p>
    </div>

    <form @submit.prevent="handlePay">
      <div class="form-row">
        <label>{{ t('payment.card') }}</label>
        <div id="card-element" ref="cardElementRef"></div>
        <div class="card-errors" v-if="cardError">{{ cardError }}</div>
      </div>

      <button type="submit" :disabled="isProcessing" class="pay-button">
        {{ isProcessing ? t('payment.processing') : t('payment.pay') + amount + currencySymbol }}
      </button>
    </form>

    <div v-if="resultMessage" class="result" :class="resultType">
      {{ resultMessage }}
    </div>

    <!-- 自定义 Loading 遮罩 -->
    <div v-if="showLoading" class="loading-overlay">
      <div class="loading-content">
        <div class="loading-spinner"></div>
        <p class="loading-text">{{ loadingText }}</p>
      </div>
    </div>

    <!-- 支付成功提示弹窗 -->
    <div v-if="showSuccessModal" class="success-overlay">
      <div class="success-modal">
        <div class="success-icon">✓</div>
        <h3>{{ t('payment.success') }}</h3>
        <p class="success-message">{{ t('payment.successMessage') }}</p>
        <p class="success-tip">{{ countDown }}{{ t('payment.autoRedirect') }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { loadStripe } from '@stripe/stripe-js'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { paySuccess } from '~/api/goods'

const { t } = useI18n()

// ---------- Props ----------
const props = defineProps({
  // 必需:Stripe 公钥 (pk_live_xxx 或 pk_test_xxx)
  publishableKey: {
    type: String,
    required: true
  },
  // 必需:PaymentIntent 的 client_secret
  clientSecret: {
    type: String,
    required: true
  },
  // 可选:用于显示的订单号
  orderNo: String,
  // 可选:金额(显示用)
  amount: [String, Number],
  // 可选:货币
  currency: {
    type: String,
    default: 'SGD'
  },
  currencySymbol: {
    type: String,
    default: '$'
  },
  orderId: {
    type: String,
    default: null
  }
})

// ---------- Events ----------
const emit = defineEmits(['close'])

// ---------- 响应式状态 ----------
const cardElementRef = ref(null)
const cardError = ref('')
const isProcessing = ref(false)
const resultMessage = ref('')
const resultType = ref('') // 'success' 或 'error'

// 自定义 loading 状态
const showLoading = ref(false)
const loadingText = ref('')


// 支付成功弹窗状态
const showSuccessModal = ref(false)
const countDown = ref(3)

let stripe = null
let cardElement = null

// ---------- 路由实例 ----------
const router = useRouter()

// ---------- 支付逻辑 ----------
const handlePay = async () => {
  if (!stripe || !cardElement) {
    resultMessage.value = t('payment.notReady')
    resultType.value = 'error'
    return
  }

  isProcessing.value = true
  resultMessage.value = ''
  resultType.value = ''

  // 显示自定义 loading
  showLoading.value = true
  loadingText.value = t('payment.processingPay')

  try {
    // 确认支付(自动处理 3D Secure)
    // payment_method 应该是一个对象,包含 card 属性

    const { error, paymentIntent } = await stripe.confirmCardPayment(prpos.clientSecret, {
      payment_method: {
        card: cardElement,
        billing_details: {
          // name: props.orderNo || 'Customer'
          name: 'cus_UPZ6xMq5ziKgm9'
        }
      }
    })

    if (error) {
      throw new Error(error.message)
    }

    if (paymentIntent.status === 'succeeded') {
      // 隐藏 loading,显示支付成功弹窗
      showLoading.value = false
      
      try {
        // 调用支付成功接口
        await paySuccess({ orderId: props.orderId })
        
        showSuccessModal.value = true;
        countDown.value = 3;
        const timer = setInterval(() => {
          countDown.value--;
          if(countDown.value <= 0) {
            clearInterval(timer)
            // 判断当前页面是否是订单列表页
            if (router.currentRoute.value.path === '/order/list') {
              // 如果当前就在订单列表页,强制刷新页面
              window.location.reload()
            } else {
              // 否则跳转到订单列表页
              router.push('/order/list')
            }
          }
        }, 1000)
      } catch (error) {
        resultMessage.value = '❌' + t('payment.fail') + error.message
        resultType.value = 'error'
      }
    } else if (paymentIntent.status === 'requires_action') {
      // 3D Secure 验证中,Stripe 会自动处理,此处一般不会执行
      loadingText.value = t('payment.verifying')
    }
  } catch (err) {
    showLoading.value = false
    resultMessage.value = '❌' +  t('payment.fail') + err.message
    resultType.value = 'error'
  } finally {
    isProcessing.value = false
  }
}

// ---------- 生命周期 ----------
onMounted(async () => {
  // 验证参数
  if (!props.publishableKey || props.publishableKey.trim() === '') {
    resultMessage.value = t('payment.noKey')
    resultType.value = 'error'
    return
  }
  
  // 加载 Stripe - 使用 props 传入的公钥,生产环境应从环境变量获取
  stripe = await loadStripe(props.publishableKey)
  if (!stripe) {
    resultMessage.value = t('payment.initFail')
    resultType.value = 'error'
    return
  }

  // 创建卡片元素
  const elements = stripe.elements()
  cardElement = elements.create('card', {
    style: {
      base: {
        fontSize: '16px',
        color: '#32325d',
        '::placeholder': { color: '#aab7c4' }
      },
      invalid: { color: '#dc3545' }
    }
  })

  // 挂载到 DOM
  if (cardElementRef.value) {
    cardElement.mount(cardElementRef.value)
  }

  // 监听输入错误
  cardElement.on('change', (event) => {
    cardError.value = event.error ? event.error.message : ''
  })
})

onUnmounted(() => {
  if (cardElement) {
    cardElement.destroy()
  }
})
</script>

<style scoped>
.payment-container {
  max-width: 500px;
  margin: 20px auto;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  font-family: system-ui, -apple-system, sans-serif;
}

.order-info {
  background: #f8f9fa;
  padding: 12px;
  border-radius: 6px;
  margin-bottom: 20px;
  font-size: 14px;
}

.form-row {
  margin-bottom: 20px;
}

.form-row label {
  display: block;
  margin-bottom: 8px;
  font-weight: 500;
}

#card-element {
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: white;
  transition: border 0.2s;
}

.card-errors {
  color: #dc3545;
  font-size: 13px;
  margin-top: 8px;
}

.pay-button {
  width: 100%;
  padding: 12px;
  background: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
}

.pay-button:hover:not(:disabled) {
  background: #0056b3;
}

.pay-button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

/* 移动端适配 */
@media (max-width: 768px) {
  .payment-container {
    max-width: 100%;
    margin: 10px;
    padding: 16px;
    border-radius: 12px;
  }

  .order-info {
    padding: 10px;
    font-size: 13px;
  }

  .form-row {
    margin-bottom: 16px;
  }

  .form-row label {
    font-size: 14px;
  }

  #card-element {
    padding: 14px 12px;
    font-size: 15px;
  }

  .pay-button {
    padding: 14px;
    font-size: 15px;
    border-radius: 8px;
  }

  .success-modal {
    padding: 32px 24px !important;
    border-radius: 16px;
  }

  .success-icon {
    width: 60px !important;
    height: 60px !important;
    font-size: 30px !important;
    line-height: 60px !important;
    margin-bottom: 16px !important;
  }

  .loading-content {
    padding: 30px 24px !important;
    border-radius: 12px;
  }

  .loading-spinner {
    width: 40px !important;
    height: 40px !important;
  }

  .loading-text {
    font-size: 14px !important;
  }
}

.result {
  margin-top: 20px;
  padding: 12px;
  border-radius: 4px;
  text-align: center;
}

.result.success {
  background: #d4edda;
  color: #155724;
}

.result.error {
  background: #f8d7da;
  color: #721c24;
}

/* 自定义 Loading 遮罩 */
.loading-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.loading-content {
  background: white;
  border-radius: 12px;
  padding: 40px;
  text-align: center;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}

.loading-spinner {
  width: 50px;
  height: 50px;
  border: 4px solid #f3f3f3;
  border-top: 4px solid #007bff;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loading-text {
  font-size: 16px;
  color: #333;
  font-weight: 500;
}

/* 支付成功弹窗 */
.success-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
}

.success-modal {
  background: white;
  border-radius: 16px;
  padding: 48px 40px;
  text-align: center;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
  max-width: 400px;
  width: 90%;
}

.success-icon {
  width: 80px;
  height: 80px;
  background: linear-gradient(135deg, #28a745, #20c997);
  border-radius: 50%;
  color: white;
  font-size: 40px;
  line-height: 80px;
  margin: 0 auto 24px;
  animation: bounce 0.5s ease-out;
}

@keyframes bounce {
  0% { transform: scale(0); }
  50% { transform: scale(1.1); }
  100% { transform: scale(1); }
}

.success-modal h3 {
  font-size: 24px;
  color: #333;
  margin-bottom: 12px;
}

.success-message {
  font-size: 16px;
  color: #666;
  margin-bottom: 8px;
}

.success-tip {
  font-size: 14px;
  color: #999;
}
</style>
相关推荐
sunrains2 小时前
uniapp x 动态Tabbar(切换无闪烁)+动角标+主题切换+自定义tabbar页面导航栏样式设置 支持服务端动态配置根据角色动态设置Tabbar
前端
牧艺2 小时前
用 Three.js 实现一个浏览器端 3D 看车的项目
前端·three.js
hunterandroid2 小时前
WorkManager:可靠的后台任务调度
前端
hunterandroid2 小时前
[Android 从零到一] Navigation Component:让页面跳转更清晰
前端
搬砖的码农2 小时前
(05)进程一关对话就没了:聊天记录怎么存、重启怎么恢复
前端·agent·ai编程
Csvn3 小时前
Vue 3 defineModel 翻车实录:多个 v-model 绑定到底怎么写?
前端·vue.js
甲维斯3 小时前
坦克大战测试全翻车了!豆包,DeepSeek,Qwen,GPT,Claude
前端·人工智能·游戏开发
乘风gg4 小时前
还在养虾吗?虾王已诞生:微信龙虾 ClawBot
前端·ai编程·claude
小小小小宇4 小时前
LLM 长期记忆构建
前端