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

519 lines
12 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 class="coupon-section">
<el-input
v-model="couponCode"
:placeholder="$t('payment.couponPlaceholder')"
size="small"
clearable
>
<template #append>
<el-button
@click="applyCoupon"
:loading="applyingCoupon"
size="small"
>
{{ $t('payment.applyCoupon') }}
</el-button>
</template>
</el-input>
<p v-if="couponError" class="coupon-error">{{ couponError }}</p>
<p v-if="couponSuccess" class="coupon-success">{{ couponSuccess }}</p>
</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 { loadStripe } from '@stripe/stripe-js'
import { useI18n } from 'vue-i18n'
// 模拟Stripe公钥生产环境中应该从环境变量获取
const STRIPE_PUBLISHABLE_KEY = 'pk_test_51SRnUTG9Oq8PDokQxhKzpPYaf5rTFR5OZ8QqTkGVtL9YUwTZFgU4ipN42Lub6NEYjXRvcIx8hvAvJGkKskDQ0pf9003uZhrC9Y'
const { t } = useI18n()
// Props
const props = defineProps({
amount: {
type: Number,
required: true // 金额(分)
},
currency: {
type: String,
default: 'usd'
},
orderId: {
type: String,
required: true
},
customerEmail: {
type: String,
default: ''
}
})
// Emits
const emit = defineEmits(['payment-success', 'payment-error', 'cancel'])
// 响应式数据
const stripe = ref(null)
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)
// 支付方式选项
// 仅支持 Stripe 卡片支付
// 计算属性
const canPay = computed(() => !!stripe.value && !!cardElement.value && !processing.value)
const finalAmount = computed(() => {
return props.amount + taxAmount.value + shippingAmount.value - discountAmount.value
})
// 初始化Stripe
const initializeStripe = async () => {
try {
stripe.value = await loadStripe(STRIPE_PUBLISHABLE_KEY)
if (!stripe.value) {
throw new Error('Failed to load Stripe')
}
// 检测当前是否为暗色主题
const isDarkMode = document.documentElement.classList.contains('dark')
elements.value = stripe.value.elements({
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#6B46C1',
colorBackground: isDarkMode ? '#1f2937' : '#ffffff',
colorText: isDarkMode ? '#f9fafb' : '#1f2937',
colorDanger: '#ef4444',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
spacingUnit: '4px',
borderRadius: '6px'
},
rules: {
'.Input': {
backgroundColor: isDarkMode ? '#374151' : '#ffffff',
borderColor: isDarkMode ? '#4b5563' : '#e5e7eb',
color: isDarkMode ? '#f9fafb' : '#1f2937'
},
'.Input:hover': {
borderColor: '#6B46C1'
},
'.Input:focus': {
borderColor: '#6B46C1',
boxShadow: '0 0 0 1px #6B46C1'
},
'.Label': {
color: isDarkMode ? '#d1d5db' : '#4b5563'
},
'.Error': {
color: '#ef4444'
}
}
}
})
// 创建卡片元素
cardElement.value = elements.value.create('card', {
style: {
base: {
fontSize: '16px',
color: isDarkMode ? '#f9fafb' : '#424770',
'::placeholder': {
color: isDarkMode ? '#9ca3af' : '#aab7c4',
},
iconColor: isDarkMode ? '#9ca3af' : '#666ee1'
},
invalid: {
color: '#9e2146',
},
},
})
// 监听卡片验证错误
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 (!stripe.value || !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 {
let paymentMethod = null
const { error, paymentMethod: pm } = await stripe.value.createPaymentMethod({
type: 'card',
card: cardElement.value,
billing_details: { email: props.customerEmail }
})
if (error) throw new Error(error.message)
paymentMethod = pm
// 模拟服务端处理支付
await new Promise(resolve => setTimeout(resolve, 2000))
// 模拟支付成功90%成功率)
if (Math.random() > 0.1) {
ElMessage.success(t('payment.success'))
emit('payment-success', {
paymentMethodId: paymentMethod?.id,
orderId: props.orderId,
amount: finalAmount.value,
currency: props.currency
})
} else {
throw new Error(t('payment.declined'))
}
} 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()
})
// 监听金额变化,重新计算费用
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>