430 lines
9.8 KiB
Vue
430 lines
9.8 KiB
Vue
<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> |