deotalandAi/apps/frontend/src/components/StripePaymentForm.vue

430 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="stripe-payment-form">
<div id="card-element" class="stripe-element"></div>
<div id="card-errors" class="stripe-errors" role="alert"></div>
<!-- 支付信息 -->
<div class="payment-info">
<div class="order-summary">
<h4>{{ $t('payment.orderSummary') }}</h4>
<div class="summary-items">
<div class="summary-item">
<span>{{ $t('payment.subtotal') }}</span>
<span>¥{{ (amount / 100).toFixed(2) }}</span>
</div>
<div class="summary-item">
<span>{{ $t('payment.tax') }}</span>
<span>¥{{ (taxAmount / 100).toFixed(2) }}</span>
</div>
<div class="summary-item">
<span>{{ $t('payment.shipping') }}</span>
<span>{{ shippingAmount > 0 ? `¥${(shippingAmount / 100).toFixed(2)}` : $t('payment.free') }}</span>
</div>
<div class="summary-divider"></div>
<div class="summary-item total">
<span>{{ $t('payment.total') }}</span>
<span>¥{{ ((amount + taxAmount + shippingAmount) / 100).toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<!-- 支付按钮 -->
<div class="payment-actions">
<el-button
@click="cancelPayment"
size="large"
:disabled="processing"
>
{{ $t('common.cancel') }}
</el-button>
<el-button
type="primary"
size="large"
@click="processPayment"
:loading="processing"
:disabled="!canPay"
>
<el-icon v-if="!processing"><Lock /></el-icon>
{{ $t('payment.payNow') }} ¥{{ ((amount + taxAmount + shippingAmount - discountAmount) / 100).toFixed(2) }}
</el-button>
</div>
<!-- 安全提示 -->
<div class="security-notice">
<el-icon><Lock /></el-icon>
<span>{{ $t('payment.securityNotice') }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElLoading } from 'element-plus'
import { CreditCard, Lock } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { PayServer } from '@deotaland/utils'
const { t } = useI18n()
// Props
const props = defineProps({
amount: {
type: Number,
required: true // 金额(分)
},
currency: {// 货币类型
type: String,
default: 'usd'
},
orderId: {// 订单ID
type: String,
required: true
},
customerEmail: {// 客户邮箱
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['payment-success', 'payment-error', 'cancel'])
const elements = ref(null)
const cardElement = ref(null)
const processing = ref(false)
const applyingCoupon = ref(false)
const couponCode = ref('')
const couponError = ref('')
const couponSuccess = ref('')
const discountAmount = ref(0)
const taxAmount = ref(0)
const shippingAmount = ref(0)
const isInitialized = ref(false) // 防止重复初始化
// 支付方式选项
// 仅支持 Stripe 卡片支付
// 计算属性
const canPay = computed(() => !processing.value)
const finalAmount = computed(() => {
return props.amount + taxAmount.value + shippingAmount.value - discountAmount.value
})
const payServer = new PayServer()
// 初始化Stripe
const initializeStripe = async () => {
// 防止重复初始化
if (isInitialized.value) {
return;
}
try {
const data = await payServer.createPaymentIntent(finalAmount.value, props.currency, {
order_id: props.orderId,
customer_email: props.customerEmail,
})
elements.value = data.element
cardElement.value = data.cardElement
isInitialized.value = true;
// 监听卡片验证错误
cardElement.value.on('change', ({ error }) => {
const displayError = document.getElementById('card-errors')
if (error) {
displayError.textContent = error.message
} else {
displayError.textContent = ''
}
})
// 挂载卡片元素到DOM
setTimeout(() => {
const cardContainer = document.getElementById('card-element')
if (cardContainer && cardElement.value) {
cardElement.value.mount('#card-element')
}
}, 100)
} catch (error) {
console.error('Error initializing Stripe:', error)
ElMessage.error(t('payment.initializationError'))
}
}
// 计算税费和运费
const calculateFees = () => {
// 模拟税费计算8%
taxAmount.value = Math.round(props.amount * 0.08)
// 模拟运费计算满99免费
shippingAmount.value = props.amount >= 9900 ? 0 : 1000
}
// 应用优惠券
const applyCoupon = async () => {
if (!couponCode.value.trim()) {
return
}
applyingCoupon.value = true
couponError.value = ''
couponSuccess.value = ''
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟优惠券逻辑
const validCoupons = {
'WELCOME10': 0.1,
'SAVE20': 0.2,
'FIRST50': 5000 // 固定金额(分)
}
const discount = validCoupons[couponCode.value.toUpperCase()]
if (discount) {
if (discount < 1) {
// 百分比折扣
discountAmount.value = Math.round(props.amount * discount)
} else {
// 固定金额折扣
discountAmount.value = Math.min(discount, props.amount)
}
couponSuccess.value = t('payment.couponApplied')
ElMessage.success(t('payment.couponSuccess'))
} else {
couponError.value = t('payment.invalidCoupon')
discountAmount.value = 0
}
} catch (error) {
couponError.value = t('payment.couponError')
ElMessage.error(t('payment.couponError'))
} finally {
applyingCoupon.value = false
}
}
// 处理支付
const processPayment = async () => {
if ( !elements.value) {
ElMessage.error(t('payment.stripeNotInitialized'))
return
}
processing.value = true
const loading = ElLoading.service({
lock: true,
text: t('payment.processing'),
background: 'rgba(0, 0, 0, 0.7)'
})
try {
const { paymentIntent } = await payServer.confirmPaymentIntent(elements.value, props.customerEmail)
if(paymentIntent.status === 'succeeded'){
ElMessage.success(t('payment.success'))
emit('payment-success', {
paymentIntent: paymentIntent,
})
}
} catch (error) {
console.error('Payment error:', error)
ElMessage.error(error.message || t('payment.failure'))
emit('payment-error', error)
} finally {
processing.value = false
loading.close()
}
}
// 取消支付
const cancelPayment = () => {
emit('cancel')
}
// 生命周期
onMounted(() => {
initializeStripe()
calculateFees()
})
onUnmounted(() => {
if (cardElement.value) {
cardElement.value.destroy()
cardElement.value = null
}
if (elements.value) {
elements.value = null
}
// 重置初始化标志
isInitialized.value = false;
})
// 监听金额变化,重新计算费用
watch(() => props.amount, () => {
calculateFees()
discountAmount.value = 0
couponCode.value = ''
couponError.value = ''
couponSuccess.value = ''
})
</script>
<style scoped>
.stripe-payment-form {
max-width: 500px;
margin: 0 auto;
padding: 24px;
}
/* 仅卡片支付样式 */
.stripe-element {
padding: 12px;
border-radius: 6px;
background: var(--card-bg, #ffffff);
margin-bottom: 24px;
border: 1px solid var(--border-color, #e5e7eb);
}
.stripe-errors {
margin-top: 8px;
color: var(--el-color-danger, #ef4444);
font-size: 14px;
}
.payment-info {
margin-bottom: 24px;
}
.order-summary {
background: var(--card-bg, #f9fafb);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
border: 1px solid var(--border-color, #e5e7eb);
}
.order-summary h4 {
margin: 0 0 12px 0;
color: var(--text-primary, #1f2937);
font-size: 16px;
font-weight: 600;
}
.summary-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: var(--text-secondary, #4b5563);
}
.summary-item.total {
font-weight: 600;
font-size: 16px;
color: var(--text-primary, #1f2937);
}
.summary-divider {
height: 1px;
background: var(--border-color, #e5e7eb);
margin: 4px 0;
}
.coupon-section {
margin-bottom: 16px;
}
.coupon-error {
color: var(--el-color-danger, #ef4444);
font-size: 12px;
margin: 4px 0 0 0;
}
.coupon-success {
color: var(--el-color-success, #10b981);
font-size: 12px;
margin: 4px 0 0 0;
}
.payment-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-bottom: 16px;
}
.security-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--el-color-success-light, #f0fdf4);
border-radius: 6px;
color: var(--el-color-success-dark, #065f46);
font-size: 12px;
}
/* 暗色主题适配 */
:root.dark .stripe-payment-form {
color: var(--text-primary, #f9fafb);
}
:root.dark .stripe-element {
background: var(--card-bg, #1f2937);
border-color: var(--border-color, #374151);
}
:root.dark .order-summary {
background: var(--card-bg, #1f2937);
border-color: var(--border-color, #374151);
}
:root.dark .order-summary h4 {
color: var(--text-primary, #f9fafb);
}
:root.dark .summary-item {
color: var(--text-secondary, #d1d5db);
}
:root.dark .summary-item.total {
color: var(--text-primary, #f9fafb);
}
:root.dark .summary-divider {
background: var(--border-color, #374151);
}
:root.dark .security-notice {
background: rgba(16, 185, 129, 0.1);
color: var(--text-secondary, #d1d5db);
border: 1px solid var(--el-color-success, #10b981);
}
/* 响应式设计 */
@media (max-width: 768px) {
.stripe-payment-form {
padding: 16px;
}
.method-tabs {
flex-direction: column;
}
.method-tab {
justify-content: center;
}
.payment-actions {
flex-direction: column-reverse;
}
.payment-actions .el-button {
width: 100%;
}
}
</style>