feat(payment): 集成 PayPal 支付并添加货币转换功能

- 集成 PayPal 支付流程,包括订单创建、支付确认和取消页面
- 实现货币转换功能,支持不支持的货币自动转换为 PayPal 支持的货币
- 在订单确认页面显示货币转换信息,包括实际支付金额和汇率
- 添加 PayPal 支付成功和取消的路由处理
- 优化商品详情页图片预览,支持鼠标悬停显示主图
- 添加货币转换相关的 API 接口和工具函数
This commit is contained in:
2025-12-23 17:49:51 +08:00
parent 2f3606e967
commit 45f6a5020d
4 changed files with 313 additions and 71 deletions

View File

@@ -31,3 +31,14 @@ export function getOrderById(id) {
}) })
} }
/**
* 计算并更新订单的货币转换信息
*/
export function calculateCurrencyConversion(data) {
return request({
url: '/order/calculate-currency-conversion',
method: 'post',
data
})
}

View File

@@ -36,6 +36,18 @@ const routes = [
name: 'OrderConfirm', name: 'OrderConfirm',
component: () => import('../views/OrderConfirm.vue') component: () => import('../views/OrderConfirm.vue')
}, },
{
path: '/paypal/success',
name: 'PayPalSuccess',
component: () => import('../views/PayPalSuccess.vue'),
meta: { isCustomerPage: true }
},
{
path: '/paypal/cancel',
name: 'PayPalCancel',
component: () => import('../views/PayPalCancel.vue'),
meta: { isCustomerPage: true }
},
{ {
path: '/checkout', path: '/checkout',
name: 'Checkout', name: 'Checkout',

View File

@@ -21,7 +21,44 @@
<el-descriptions :column="2" border> <el-descriptions :column="2" border>
<el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item> <el-descriptions-item label="订单号">{{ order.orderNo }}</el-descriptions-item>
<el-descriptions-item label="订单金额"> <el-descriptions-item label="订单金额">
<span class="order-amount">{{ order.currency }} {{ formatPrice(order.totalAmount) }}</span> <div class="order-amount-section">
<div class="original-amount">
<span class="order-amount">{{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
</div>
<!-- 货币转换信息 - 更醒目的提示 -->
<div v-if="order.paymentCurrency && order.paymentCurrency !== order.currency" class="currency-conversion-info">
<el-alert
type="warning"
:closable="false"
show-icon
:effect="'dark'"
class="conversion-alert-box"
>
<template #title>
<div class="conversion-alert-content">
<div class="conversion-title">
<el-icon class="warning-icon"><Warning /></el-icon>
<strong class="title-text">您将以{{ getCurrencyName(order.paymentCurrency) }}支付</strong>
</div>
<div class="conversion-main-info">
<div class="payment-amount-highlight">
<span class="label">实际费用</span>
<span class="payment-amount-large">{{ order.paymentCurrency }} {{ formatPrice(order.paymentAmount) }}</span>
</div>
<div v-if="order.exchangeRate" class="exchange-rate-info">
<span class="equivalent-amount"> {{ order.currency }} {{ formatPrice(order.totalAmount) }}</span>
<span class="rate-value">汇率{{ formatRate(order.exchangeRate) }}</span>
</div>
</div>
<div v-if="order.rateLockedAt" class="rate-locked-info">
<el-icon><Clock /></el-icon>
<span>汇率锁定时间{{ formatDateTime(order.rateLockedAt) }}</span>
</div>
</div>
</template>
</el-alert>
</div>
</div>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="商品名称" :span="2">{{ order.productName }}</el-descriptions-item> <el-descriptions-item label="商品名称" :span="2">{{ order.productName }}</el-descriptions-item>
<el-descriptions-item label="SKU名称" :span="2">{{ order.skuName }}</el-descriptions-item> <el-descriptions-item label="SKU名称" :span="2">{{ order.skuName }}</el-descriptions-item>
@@ -91,11 +128,10 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Money } from '@element-plus/icons-vue' import { Money, Clock, Warning } from '@element-plus/icons-vue'
import { getOrderByOrderNo } from '../api/order' import { getOrderByOrderNo, calculateCurrencyConversion } from '../api/order'
import { createPaymentOrder } from '../api/payment' import { createPayPalOrder, getPayPalOrder, capturePayPalOrder } from '../api/paypal'
import { formatAmount } from '../utils/helpers' import { formatAmount } from '../utils/helpers'
import { generateOrderId } from '../utils/helpers'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -132,6 +168,30 @@ const formatPrice = (price) => {
return formatAmount(price) return formatAmount(price)
} }
// 获取货币名称
const getCurrencyName = (currencyCode) => {
const currencyNames = {
'USD': '美元',
'EUR': '欧元',
'GBP': '英镑',
'CNY': '人民币',
'MYR': '马来西亚林吉特',
'VND': '越南盾',
'JPY': '日元',
'KRW': '韩元',
'THB': '泰铢',
'SGD': '新加坡元',
'HKD': '港币'
}
return currencyNames[currencyCode] || currencyCode
}
// 格式化汇率
const formatRate = (rate) => {
if (!rate) return ''
return rate.toFixed(6)
}
// 格式化日期时间 // 格式化日期时间
const formatDateTime = (dateTime) => { const formatDateTime = (dateTime) => {
if (!dateTime) return '-' if (!dateTime) return '-'
@@ -169,6 +229,44 @@ const loadOrder = async () => {
const response = await getOrderByOrderNo(orderNo) const response = await getOrderByOrderNo(orderNo)
if (response.code === '0000' && response.data) { if (response.code === '0000' && response.data) {
order.value = response.data order.value = response.data
// 如果订单未支付且需要货币转换,提前计算货币转换信息
if (order.value.paymentStatus === 'UNPAID' && order.value.currency) {
// 检查是否需要货币转换PayPal支持的货币列表
const supportedCurrencies = ['USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'CNY', 'HKD', 'SGD', 'NZD',
'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'MXN', 'BRL', 'INR', 'KRW', 'THB']
const needsConversion = !supportedCurrencies.includes(order.value.currency)
// 如果订单还没有货币转换信息,或者需要更新,则计算
if (needsConversion && (!order.value.paymentCurrency || order.value.paymentCurrency === order.value.currency)) {
try {
const conversionResponse = await calculateCurrencyConversion({
orderNo: order.value.orderNo,
originalCurrency: order.value.currency,
originalAmount: order.value.totalAmount
})
if (conversionResponse.code === '0000' && conversionResponse.data) {
const conversion = conversionResponse.data
// 更新订单显示信息
order.value = {
...order.value,
originalCurrency: conversion.originalCurrency,
originalAmount: conversion.originalAmount,
paymentCurrency: conversion.paymentCurrency,
paymentAmount: conversion.paymentAmount,
exchangeRate: conversion.exchangeRate,
rateLockedAt: conversion.rateLockedAt
}
console.log('货币转换信息已计算并更新')
}
} catch (error) {
console.warn('计算货币转换信息失败:', error)
// 不显示错误,因为不影响订单显示
}
}
}
} else { } else {
ElMessage.error(response.message || '获取订单信息失败') ElMessage.error(response.message || '获取订单信息失败')
order.value = null order.value = null
@@ -182,7 +280,7 @@ const loadOrder = async () => {
} }
} }
// 处理支付 // 处理PayPal支付步骤1-4
const handlePay = async () => { const handlePay = async () => {
if (!order.value) { if (!order.value) {
ElMessage.error('订单信息不存在') ElMessage.error('订单信息不存在')
@@ -197,77 +295,73 @@ const handlePay = async () => {
payLoading.value = true payLoading.value = true
try { try {
// 构建支付请求数据 // 步骤2构建PayPal订单创建请求
const paymentData = { const paypalOrderData = {
merchantTransactionId: generateOrderId(), intent: 'CAPTURE', // 立即捕获
amount: order.value.totalAmount.toFixed(2), referenceId: order.value.orderNo, // ERP订单号
currency: order.value.currency, amount: order.value.totalAmount,
paymentType: 'SALE', currencyCode: order.value.currency,
merchantUserId: '', itemName: order.value.productName,
shopperResultUrl: `${window.location.origin}/result`, itemDescription: order.value.skuName,
shopperCancelUrl: `${window.location.origin}/result`, itemSku: order.value.skuName,
signType: 'MD5', itemQuantity: order.value.quantity,
language: 'zh', itemUnitAmount: order.value.unitPrice,
threeDSecure: 'N', returnUrl: `${window.location.origin}/paypal/success?orderNo=${order.value.orderNo}`,
riskInfo: { cancelUrl: `${window.location.origin}/paypal/cancel?orderNo=${order.value.orderNo}`,
customer: { shippingName: order.value.shippingName,
firstName: order.value.customerName.split(' ')[0] || order.value.customerName, shippingAddressLine1: order.value.shippingStreet,
lastName: order.value.customerName.split(' ').slice(1).join(' ') || '', shippingCity: order.value.shippingCity,
email: order.value.customerEmail || '', shippingState: order.value.shippingState || null,
phone: order.value.customerPhone, shippingPostalCode: order.value.shippingPostcode || null,
registerTime: new Date().toISOString().replace(/[-:]/g, '').split('.')[0], shippingCountryCode: order.value.shippingCountry,
registerIp: '', emailAddress: order.value.customerEmail || null
registerTerminal: 'PC',
registerRange: '1',
orderTime: new Date(order.value.createTime).toISOString().replace(/[-:]/g, '').split('.')[0],
orderIp: '',
orderCountry: order.value.shippingCountry || 'US'
},
goods: [{
name: order.value.productName,
description: order.value.skuName,
sku: order.value.skuName,
averageUnitPrice: order.value.unitPrice.toFixed(2),
number: order.value.quantity.toString(),
virtualProduct: 'N'
}],
shipping: {
firstName: order.value.shippingName.split(' ')[0] || order.value.shippingName,
lastName: order.value.shippingName.split(' ').slice(1).join(' ') || '',
street: order.value.shippingStreet,
city: order.value.shippingCity,
state: order.value.shippingState || '',
postcode: order.value.shippingPostcode || '',
country: order.value.shippingCountry
},
billing: {
firstName: order.value.shippingName.split(' ')[0] || order.value.shippingName,
lastName: order.value.shippingName.split(' ').slice(1).join(' ') || '',
street: order.value.shippingStreet,
city: order.value.shippingCity,
state: order.value.shippingState || '',
postcode: order.value.shippingPostcode || '',
country: order.value.shippingCountry
}
},
notificationUrl: `${window.location.origin}/api/callback/pingpong`
} }
const response = await createPaymentOrder(paymentData) // 调用后端创建PayPal订单
const response = await createPayPalOrder(paypalOrderData)
if (response.code === '0000' && response.data && response.data.token) { if (response.code === '0000' && response.data) {
ElMessage.success('正在跳转到支付页面...') const responseData = response.data
// 跳转到收银台页面 const paypalOrder = responseData.paypalOrder || responseData // 兼容新旧格式
router.push({ const currencyConversion = responseData.currencyConversion
path: '/checkout',
query: { token: response.data.token } // 如果有货币转换信息,更新订单显示
}) if (currencyConversion) {
order.value = {
...order.value,
originalCurrency: currencyConversion.originalCurrency,
originalAmount: currencyConversion.originalAmount,
paymentCurrency: currencyConversion.paymentCurrency,
paymentAmount: currencyConversion.paymentAmount,
exchangeRate: currencyConversion.exchangeRate,
rateLockedAt: currencyConversion.rateLockedAt
}
// 显示货币转换提示
if (currencyConversion.conversionRequired) {
ElMessage.info({
message: `您将以${getCurrencyName(currencyConversion.paymentCurrency)}支付,实际费用为:${currencyConversion.paymentCurrency} ${formatPrice(currencyConversion.paymentAmount)}`,
duration: 5000
})
}
}
// 步骤3-4获取approval_url并跳转到PayPal登录页
const approvalLink = paypalOrder.links?.find(link => link.rel === 'payer-action')
if (approvalLink && approvalLink.href) {
ElMessage.success('正在跳转到PayPal支付页面...')
// 步骤4跳转到PayPal登录页
window.location.href = approvalLink.href
} else {
ElMessage.error('获取PayPal支付链接失败')
}
} else { } else {
ElMessage.error(response.message || '创建支付订单失败') ElMessage.error(response.message || '创建PayPal订单失败')
} }
} catch (error) { } catch (error) {
console.error('创建支付订单失败:', error) console.error('创建PayPal订单失败:', error)
ElMessage.error(error.response?.data?.message || '创建支付订单失败,请稍后重试') ElMessage.error(error.response?.data?.message || '创建PayPal订单失败,请稍后重试')
} finally { } finally {
payLoading.value = false payLoading.value = false
} }
@@ -326,5 +420,115 @@ onMounted(() => {
padding-top: 20px; padding-top: 20px;
border-top: 1px solid #e4e7ed; border-top: 1px solid #e4e7ed;
} }
.order-amount-section {
display: flex;
flex-direction: column;
}
.currency-conversion-info {
margin-top: 15px;
}
.conversion-alert-box {
border: 2px solid #e6a23c;
border-radius: 8px;
background: linear-gradient(135deg, #fff7e6 0%, #fff3d9 100%);
box-shadow: 0 4px 12px rgba(230, 162, 60, 0.2);
}
.conversion-alert-content {
line-height: 1.8;
padding: 5px 0;
}
.conversion-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 18px;
}
.warning-icon {
font-size: 24px;
color: #e6a23c;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.title-text {
color: #e6a23c;
font-size: 18px;
font-weight: 700;
}
.conversion-main-info {
background: #fff;
padding: 15px;
border-radius: 6px;
margin: 10px 0;
border-left: 4px solid #e6a23c;
}
.payment-amount-highlight {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 8px;
}
.payment-amount-highlight .label {
font-size: 16px;
color: #606266;
font-weight: 600;
}
.payment-amount-large {
font-weight: 800;
color: #e6a23c;
font-size: 28px;
text-shadow: 0 2px 4px rgba(230, 162, 60, 0.3);
}
.exchange-rate-info {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #dcdfe6;
}
.equivalent-amount {
color: #606266;
font-size: 15px;
font-weight: 500;
}
.rate-value {
color: #909399;
font-size: 13px;
font-style: italic;
}
.rate-locked-info {
margin-top: 10px;
font-size: 13px;
color: #909399;
display: flex;
align-items: center;
gap: 6px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
}
</style> </style>

View File

@@ -41,6 +41,8 @@
class="thumbnail-item" class="thumbnail-item"
:class="{ active: currentImageIndex === index }" :class="{ active: currentImageIndex === index }"
@click="selectImage(index)" @click="selectImage(index)"
@mouseenter="hoverImage(img)"
@mouseleave="hoverImage(null)"
> >
<el-image :src="img" fit="cover" class="thumbnail-img" /> <el-image :src="img" fit="cover" class="thumbnail-img" />
</div> </div>
@@ -263,6 +265,8 @@ const router = useRouter()
const loading = ref(false) const loading = ref(false)
const productLoading = ref(true) const productLoading = ref(true)
const currentImageIndex = ref(0) const currentImageIndex = ref(0)
// 鼠标悬停的主图(优先展示)
const hoveredImage = ref(null)
const quantity = ref(1) const quantity = ref(1)
const activeTab = ref('detail') const activeTab = ref('detail')
const selectedSku = ref(null) const selectedSku = ref(null)
@@ -324,12 +328,19 @@ const currentCurrencySkus = computed(() => {
// 当前显示的图片 // 当前显示的图片
const currentImage = computed(() => { const currentImage = computed(() => {
// 1) 若有悬停主图,优先展示
if (hoveredImage.value) {
return hoveredImage.value
}
// 2) 已选SKU且有SKU图
if (selectedSku.value && selectedSku.value.skuImage) { if (selectedSku.value && selectedSku.value.skuImage) {
return selectedSku.value.skuImage return selectedSku.value.skuImage
} }
// 3) 主图列表
if (product.value.mainImages && product.value.mainImages.length > 0) { if (product.value.mainImages && product.value.mainImages.length > 0) {
return product.value.mainImages[currentImageIndex.value] || product.value.mainImages[0] return product.value.mainImages[currentImageIndex.value] || product.value.mainImages[0]
} }
// 4) 兜底单张主图
return product.value.mainImage || '' return product.value.mainImage || ''
}) })
@@ -378,6 +389,10 @@ const formatSize = (sizeJson) => {
const selectImage = (index) => { const selectImage = (index) => {
currentImageIndex.value = index currentImageIndex.value = index
} }
// 悬停主图时优先展示该主图
const hoverImage = (img) => {
hoveredImage.value = img
}
// 立即购买 // 立即购买
const handleBuyNow = () => { const handleBuyNow = () => {