feat(payment): 新增创建支付订单功能

- 添加创建订单页面,支持完整的支付信息填写
- 实现商品信息展示与自动填充功能
- 集成风控信息、收货地址和账单地址表单
- 支持自动生成商户订单号
- 实现表单验证和提交逻辑
- 添加订单创建成功后的跳转逻辑
- 集成Element Plus组件库优化界面交互
- 添加路由配置支持商品ID或链接码访问
- 实现价格格式化和数据显示优化
- 添加基础的错误处理和用户提示机制
This commit is contained in:
2025-12-22 13:11:13 +08:00
parent ed745ee6a5
commit 0cfe1e6942
7 changed files with 2464 additions and 1 deletions

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MT Pay - PingPong支付</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1580
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
<template>
<div v-if="loading" class="loading-overlay">
<el-loading
:text="text"
:spinner="spinner"
/>
</div>
</template>
<script setup>
defineProps({
loading: {
type: Boolean,
default: false
},
text: {
type: String,
default: '加载中...'
},
spinner: {
type: String,
default: 'el-icon-loading'
}
})
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background-color: rgba(255, 255, 255, 0.8);
}
</style>

32
src/main.js Normal file
View File

@@ -0,0 +1,32 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(router)
app.use(ElementPlus)
// 添加错误处理
app.config.errorHandler = (err, instance, info) => {
console.error('Vue错误:', err)
console.error('错误信息:', info)
console.error('组件实例:', instance)
}
// 挂载应用
try {
app.mount('#app')
console.log('Vue应用已成功挂载')
} catch (error) {
console.error('应用挂载失败:', error)
}

View File

@@ -14,7 +14,9 @@ const routes = [
{
path: '/product/:id',
name: 'ProductDetail',
component: ProductDetail
component: ProductDetail,
// 支持商品ID数字或链接码32位字符串
props: true
},
{
path: '/create-order',

645
src/views/CreateOrder.vue Normal file
View File

@@ -0,0 +1,645 @@
<template>
<div class="create-order">
<el-card>
<template #header>
<div class="card-header">
<span>创建支付订单</span>
</div>
</template>
<!-- 商品信息展示卡片 -->
<el-card v-if="productInfo" class="product-info-card" shadow="never">
<template #header>
<div class="product-card-header">
<span>商品信息</span>
</div>
</template>
<div class="product-info-content">
<div class="product-info-main">
<el-image
:src="productInfo.image"
fit="cover"
class="product-info-image"
/>
<div class="product-info-details">
<div class="product-info-name">{{ productInfo.name }}</div>
<div class="product-info-subtitle">{{ productInfo.subtitle }}</div>
<!-- SKU信息 -->
<div class="product-info-sku" v-if="productInfo.sku">
<span class="sku-label">商品编码</span>
<span class="sku-value">{{ productInfo.sku }}</span>
</div>
<!-- 规格信息 -->
<div class="product-info-specs" v-if="productInfo.specs && productInfo.specs.length > 0">
<div
v-for="(spec, index) in productInfo.specs"
:key="index"
class="product-spec-item"
>
<span class="spec-label">{{ spec.name }}</span>
<span class="spec-value">{{ spec.label }}</span>
</div>
</div>
<!-- 价格和数量 -->
<div class="product-info-price">
<span class="price-label">单价</span>
<span class="price-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price) }}</span>
<span class="quantity-label">数量</span>
<span class="quantity-value">x{{ productInfo.quantity }}</span>
<span class="total-label">小计</span>
<span class="total-value">{{ productInfo.currency }} {{ formatPrice(productInfo.price * productInfo.quantity) }}</span>
</div>
</div>
</div>
</div>
</el-card>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="150px"
label-position="left"
>
<el-form-item label="商户订单号" prop="merchantTransactionId">
<el-input
v-model="form.merchantTransactionId"
placeholder="请输入商户订单号(全局唯一)"
clearable
/>
<el-button
type="primary"
link
@click="generateOrderIdLocal"
style="margin-left: 10px"
>
自动生成
</el-button>
</el-form-item>
<el-form-item label="交易金额" prop="amount">
<el-input-number
v-model="form.amount"
:precision="2"
:min="0.01"
:max="999999.99"
placeholder="请输入交易金额"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="交易币种" prop="currency">
<el-select v-model="form.currency" placeholder="请选择币种" style="width: 100%">
<el-option label="USD - 美元" value="USD" />
<el-option label="EUR - 欧元" value="EUR" />
<el-option label="GBP - 英镑" value="GBP" />
<el-option label="CNY - 人民币" value="CNY" />
<el-option label="JPY - 日元" value="JPY" />
</el-select>
</el-form-item>
<el-form-item label="交易类型" prop="paymentType">
<el-radio-group v-model="form.paymentType">
<el-radio label="SALE">直接付款</el-radio>
<el-radio label="AUTH">预授权</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="商户用户ID" prop="merchantUserId">
<el-input
v-model="form.merchantUserId"
placeholder="请输入商户用户ID可选"
clearable
/>
</el-form-item>
<el-form-item label="结果重定向URL" prop="shopperResultUrl">
<el-input
v-model="form.shopperResultUrl"
placeholder="支付完成后跳转的URL"
/>
</el-form-item>
<el-form-item label="取消重定向URL" prop="shopperCancelUrl">
<el-input
v-model="form.shopperCancelUrl"
placeholder="取消支付后跳转的URL"
/>
</el-form-item>
<el-divider>风控信息</el-divider>
<el-form-item label="客户姓名" prop="riskInfo.customer.firstName">
<el-input
v-model="form.riskInfo.customer.firstName"
placeholder="名"
style="width: 48%"
/>
<el-input
v-model="form.riskInfo.customer.lastName"
placeholder="姓"
style="width: 48%; margin-left: 4%"
/>
</el-form-item>
<el-form-item label="客户邮箱" prop="riskInfo.customer.email">
<el-input
v-model="form.riskInfo.customer.email"
placeholder="请输入邮箱"
/>
</el-form-item>
<el-form-item label="客户电话" prop="riskInfo.customer.phone">
<el-input
v-model="form.riskInfo.customer.phone"
placeholder="请输入电话"
/>
</el-form-item>
<el-divider>商品信息</el-divider>
<el-form-item label="商品名称" prop="riskInfo.goods.0.name">
<el-input
v-model="form.riskInfo.goods[0].name"
placeholder="请输入商品名称"
/>
</el-form-item>
<el-form-item label="商品描述" prop="riskInfo.goods.0.description">
<el-input
v-model="form.riskInfo.goods[0].description"
type="textarea"
:rows="2"
placeholder="请输入商品描述"
/>
</el-form-item>
<el-form-item label="商品单价" prop="riskInfo.goods.0.averageUnitPrice">
<el-input-number
v-model="form.riskInfo.goods[0].averageUnitPrice"
:precision="2"
:min="0.01"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="商品数量" prop="riskInfo.goods.0.number">
<el-input-number
v-model="form.riskInfo.goods[0].number"
:min="1"
style="width: 100%"
/>
</el-form-item>
<el-divider>收货地址</el-divider>
<el-form-item label="收货人姓名" prop="riskInfo.shipping.firstName">
<el-input
v-model="form.riskInfo.shipping.firstName"
placeholder="名"
style="width: 48%"
/>
<el-input
v-model="form.riskInfo.shipping.lastName"
placeholder="姓"
style="width: 48%; margin-left: 4%"
/>
</el-form-item>
<el-form-item label="街道地址" prop="riskInfo.shipping.street">
<el-input
v-model="form.riskInfo.shipping.street"
placeholder="请输入街道地址"
/>
</el-form-item>
<el-form-item label="城市" prop="riskInfo.shipping.city">
<el-input
v-model="form.riskInfo.shipping.city"
placeholder="请输入城市"
style="width: 48%"
/>
<el-input
v-model="form.riskInfo.shipping.state"
placeholder="州/省(可选)"
style="width: 48%; margin-left: 4%"
/>
</el-form-item>
<el-form-item label="邮编" prop="riskInfo.shipping.postcode">
<el-input
v-model="form.riskInfo.shipping.postcode"
placeholder="请输入邮编"
style="width: 48%"
/>
<el-select
v-model="form.riskInfo.shipping.country"
placeholder="国家"
style="width: 48%; margin-left: 4%"
>
<el-option label="US - 美国" value="US" />
<el-option label="CN - 中国" value="CN" />
<el-option label="GB - 英国" value="GB" />
<el-option label="DE - 德国" value="DE" />
<el-option label="FR - 法国" value="FR" />
</el-select>
</el-form-item>
<el-divider>账单地址</el-divider>
<el-form-item label="账单人姓名" prop="riskInfo.billing.firstName">
<el-input
v-model="form.riskInfo.billing.firstName"
placeholder="名"
style="width: 48%"
/>
<el-input
v-model="form.riskInfo.billing.lastName"
placeholder="姓"
style="width: 48%; margin-left: 4%"
/>
</el-form-item>
<el-form-item label="账单街道地址" prop="riskInfo.billing.street">
<el-input
v-model="form.riskInfo.billing.street"
placeholder="请输入街道地址"
/>
</el-form-item>
<el-form-item label="账单城市" prop="riskInfo.billing.city">
<el-input
v-model="form.riskInfo.billing.city"
placeholder="请输入城市"
style="width: 48%"
/>
<el-input
v-model="form.riskInfo.billing.state"
placeholder="州/省(可选)"
style="width: 48%; margin-left: 4%"
/>
</el-form-item>
<el-form-item label="账单邮编" prop="riskInfo.billing.postcode">
<el-input
v-model="form.riskInfo.billing.postcode"
placeholder="请输入邮编"
style="width: 48%"
/>
<el-select
v-model="form.riskInfo.billing.country"
placeholder="国家"
style="width: 48%; margin-left: 4%"
>
<el-option label="US - 美国" value="US" />
<el-option label="CN - 中国" value="CN" />
<el-option label="GB - 英国" value="GB" />
<el-option label="DE - 德国" value="DE" />
<el-option label="FR - 法国" value="FR" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="loading">
创建订单并支付
</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createPaymentOrder } from '../api/payment'
import { generateOrderId, formatAmount } from '../utils/helpers'
const router = useRouter()
const route = useRoute()
const formRef = ref()
const loading = ref(false)
const productInfo = ref(null)
const form = reactive({
merchantTransactionId: '',
amount: '',
currency: 'USD',
paymentType: 'SALE',
merchantUserId: '',
shopperResultUrl: `${window.location.origin}/result`,
shopperCancelUrl: `${window.location.origin}/result`,
riskInfo: {
customer: {
firstName: '',
lastName: '',
email: '',
phone: '',
registerTime: new Date().toISOString().replace(/[-:]/g, '').split('.')[0],
registerIp: '',
registerTerminal: 'PC',
registerRange: '1',
orderTime: new Date().toISOString().replace(/[-:]/g, '').split('.')[0],
orderIp: '',
orderCountry: 'US'
},
goods: [{
name: '',
description: '',
averageUnitPrice: '',
number: '1',
virtualProduct: 'N'
}],
shipping: {
firstName: '',
lastName: '',
street: '',
city: '',
state: '',
postcode: '',
country: 'US'
},
billing: {
firstName: '',
lastName: '',
street: '',
city: '',
state: '',
postcode: '',
country: 'US'
}
}
})
const rules = {
merchantTransactionId: [
{ required: true, message: '请输入商户订单号', trigger: 'blur' }
],
amount: [
{ required: true, message: '请输入交易金额', trigger: 'blur' }
],
currency: [
{ required: true, message: '请选择交易币种', trigger: 'change' }
],
paymentType: [
{ required: true, message: '请选择交易类型', trigger: 'change' }
],
shopperResultUrl: [
{ required: true, message: '请输入结果重定向URL', trigger: 'blur' }
],
shopperCancelUrl: [
{ required: true, message: '请输入取消重定向URL', trigger: 'blur' }
]
}
const generateOrderIdLocal = () => {
form.merchantTransactionId = generateOrderId()
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) {
ElMessage.error('请填写完整信息')
return
}
loading.value = true
try {
// 格式化数据
const requestData = {
...form,
amount: form.amount.toFixed(2),
signType: 'MD5',
language: 'zh',
threeDSecure: 'N',
riskInfo: {
...form.riskInfo,
goods: form.riskInfo.goods.map(g => ({
...g,
averageUnitPrice: g.averageUnitPrice.toFixed(2),
number: g.number.toString()
}))
}
}
const response = await createPaymentOrder(requestData)
if (response.code === '0000') {
ElMessage.success('订单创建成功')
// 跳转到收银台页面
router.push({
path: '/checkout',
query: { token: response.data.token }
})
} else {
ElMessage.error(response.message || '创建订单失败')
}
} catch (error) {
console.error('创建订单失败:', error)
ElMessage.error(error.response?.data?.message || '创建订单失败,请稍后重试')
} finally {
loading.value = false
}
})
}
const resetForm = () => {
if (!formRef.value) return
formRef.value.resetFields()
generateOrderIdLocal()
}
// 格式化价格
const formatPrice = (price) => {
return formatAmount(price)
}
// 从路由参数获取商品信息
onMounted(() => {
if (route.query.data) {
try {
const data = JSON.parse(decodeURIComponent(route.query.data))
if (data.product) {
productInfo.value = data.product
// 自动填充表单
form.amount = (data.product.price * data.product.quantity).toFixed(2)
form.currency = data.product.currency || 'USD'
form.riskInfo.goods[0].name = data.product.name
form.riskInfo.goods[0].description = `${data.product.subtitle} ${data.product.specs.map(s => `${s.name}:${s.label}`).join(';')}`
form.riskInfo.goods[0].averageUnitPrice = data.product.price.toFixed(2)
form.riskInfo.goods[0].number = data.product.quantity.toString()
// 如果有SKU添加到商品描述中
if (data.product.sku) {
form.riskInfo.goods[0].sku = data.product.sku
}
}
} catch (error) {
console.error('解析商品信息失败:', error)
}
}
})
// 初始化时生成订单号
generateOrderIdLocal()
</script>
<style scoped>
.create-order {
max-width: 1000px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 18px;
font-weight: bold;
}
/* 商品信息卡片 */
.product-info-card {
margin-bottom: 20px;
border: 1px solid #e4e7ed;
}
.product-card-header {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.product-info-content {
padding: 10px 0;
}
.product-info-main {
display: flex;
gap: 20px;
}
.product-info-image {
width: 120px;
height: 120px;
border-radius: 8px;
border: 1px solid #e4e7ed;
flex-shrink: 0;
}
.product-info-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.product-info-name {
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.5;
}
.product-info-subtitle {
font-size: 14px;
color: #909399;
line-height: 1.5;
}
.product-info-sku {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 4px;
border-left: 3px solid #409eff;
}
.sku-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.sku-value {
font-size: 14px;
color: #303133;
font-weight: 600;
font-family: 'Courier New', monospace;
background: #fff;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #dcdfe6;
}
.product-info-specs {
display: flex;
flex-wrap: wrap;
gap: 15px;
}
.product-spec-item {
display: flex;
align-items: center;
gap: 5px;
}
.spec-label {
font-size: 14px;
color: #909399;
}
.spec-value {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.product-info-price {
display: flex;
align-items: center;
gap: 15px;
padding: 12px;
background: #f9fafb;
border-radius: 6px;
margin-top: 10px;
}
.price-label,
.quantity-label,
.total-label {
font-size: 14px;
color: #606266;
}
.price-value {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.quantity-value {
font-size: 14px;
color: #303133;
font-weight: 500;
}
.total-label {
margin-left: auto;
font-weight: 600;
}
.total-value {
font-size: 18px;
color: #f56c6c;
font-weight: 700;
}
</style>

152
src/views/OrderQuery.vue Normal file
View File

@@ -0,0 +1,152 @@
<template>
<div class="order-query">
<el-card>
<template #header>
<div class="card-header">
<span>订单查询</span>
</div>
</template>
<el-form :inline="true" :model="queryForm" class="query-form">
<el-form-item label="商户订单号">
<el-input
v-model="queryForm.merchantTransactionId"
placeholder="请输入商户订单号"
clearable
style="width: 300px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading">
查询
</el-button>
</el-form-item>
</el-form>
<el-divider />
<div v-if="orderData" class="order-detail">
<el-descriptions title="订单详情" :column="2" border>
<el-descriptions-item label="商户订单号">
{{ orderData.merchantTransactionId }}
</el-descriptions-item>
<el-descriptions-item label="PingPong交易流水号">
{{ orderData.transactionId || '暂无' }}
</el-descriptions-item>
<el-descriptions-item label="订单状态">
<el-tag :type="getStatusTagType(orderData.status)">
{{ getStatusText(orderData.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="交易金额">
{{ orderData.amount }} {{ orderData.currency }}
</el-descriptions-item>
<el-descriptions-item label="交易类型">
{{ orderData.paymentType === 'SALE' ? '直接付款' : '预授权' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ orderData.createTime }}
</el-descriptions-item>
</el-descriptions>
<div class="action-buttons" style="margin-top: 20px">
<el-button type="primary" @click="goToPay" v-if="orderData.status === 'PENDING'">
继续支付
</el-button>
<el-button @click="goToCreate">创建新订单</el-button>
</div>
</div>
<el-empty v-else-if="!loading" description="请输入订单号进行查询" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getOrderStatus } from '../api/payment'
import { getStatusText, getStatusTagType } from '../utils/helpers'
const router = useRouter()
const loading = ref(false)
const orderData = ref(null)
const queryForm = reactive({
merchantTransactionId: ''
})
const handleQuery = async () => {
if (!queryForm.merchantTransactionId) {
ElMessage.warning('请输入商户订单号')
return
}
loading.value = true
orderData.value = null
try {
const response = await getOrderStatus(queryForm.merchantTransactionId)
if (response.code === '0000' && response.data) {
orderData.value = response.data
ElMessage.success('查询成功')
} else {
ElMessage.error(response.message || '订单不存在')
orderData.value = null
}
} catch (error) {
console.error('查询订单失败:', error)
ElMessage.error('查询失败,请稍后重试')
orderData.value = null
} finally {
loading.value = false
}
}
const goToPay = () => {
if (orderData.value && orderData.value.token) {
router.push({
path: '/checkout',
query: { token: orderData.value.token }
})
} else {
ElMessage.warning('该订单无法继续支付,请创建新订单')
}
}
const goToCreate = () => {
router.push('/')
}
</script>
<style scoped>
.order-query {
max-width: 1000px;
margin: 0 auto;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
.query-form {
margin-bottom: 20px;
}
.order-detail {
margin-top: 20px;
}
.action-buttons {
text-align: center;
}
.action-buttons .el-button {
margin: 0 10px;
}
</style>