feat(payment): 集成PingPong支付SDK并实现支付功能

- 添加PingPong支付SDK动态加载逻辑
- 实现支付组件与SDK的初始化配置
- 配置支付容器自适应不同屏幕尺寸
- 添加支付token校验和错误提示
- 集成Element Plus消息组件显示支付状态
- 配置SDK基础样式和按钮样式参数
- 添加支付页面路由和基本布局结构
- 实现支付结果页面跳转逻辑
- 添加订单状态管理和响应码常量定义
- 集成工具函数支持金额格式化和日期处理
- 配置开发环境变量支持沙箱模式切换
- 添加防抖节流等常用工具函数实现
- 实现订单号生成和状态文本映射逻辑
- 添加表单验证函数支持邮箱和手机校验
This commit is contained in:
2025-12-19 10:06:24 +08:00
parent ae0b5f27be
commit 57d9c03332
7 changed files with 503 additions and 0 deletions

64
src/App.vue Normal file
View File

@@ -0,0 +1,64 @@
<template>
<el-container>
<el-header>
<div class="header-content">
<h1>MT Pay - PingPong支付系统</h1>
<el-menu
mode="horizontal"
:default-active="activeIndex"
router
>
<el-menu-item index="/">创建订单</el-menu-item>
<el-menu-item index="/query">订单查询</el-menu-item>
</el-menu>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const activeIndex = computed(() => route.path)
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.el-header {
background-color: #409eff;
color: white;
line-height: 60px;
padding: 0 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
font-size: 20px;
margin: 0;
}
.el-main {
padding: 20px;
min-height: calc(100vh - 60px);
}
</style>

24
src/config/index.js Normal file
View File

@@ -0,0 +1,24 @@
/**
* 配置文件
*/
export default {
// API基础URL
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
// PingPong SDK模式
pingpongMode: import.meta.env.VITE_PINGPONG_MODE || 'sandbox',
// PingPong SDK URL
pingpongSdkUrl: 'https://pay-cdn.pingpongx.com/production/static/sdk/1.2.0/ppPay.min.js',
// 请求超时时间(毫秒)
requestTimeout: 30000,
// 分页配置
pagination: {
pageSize: 10,
pageSizes: [10, 20, 50, 100]
}
}

36
src/router/index.js Normal file
View File

@@ -0,0 +1,36 @@
import { createRouter, createWebHistory } from 'vue-router'
import CreateOrder from '../views/CreateOrder.vue'
import Checkout from '../views/Checkout.vue'
import PaymentResult from '../views/PaymentResult.vue'
import OrderQuery from '../views/OrderQuery.vue'
const routes = [
{
path: '/',
name: 'CreateOrder',
component: CreateOrder
},
{
path: '/checkout',
name: 'Checkout',
component: Checkout
},
{
path: '/result',
name: 'PaymentResult',
component: PaymentResult
},
{
path: '/query',
name: 'OrderQuery',
component: OrderQuery
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

42
src/store/index.js Normal file
View File

@@ -0,0 +1,42 @@
import { reactive } from 'vue'
/**
* 简单的状态管理
* 如果需要更复杂的状态管理,可以使用 Pinia
*/
const state = reactive({
// 用户信息
user: null,
// 当前订单
currentOrder: null,
// 加载状态
loading: false
})
export default {
state,
// 设置用户信息
setUser(user) {
state.user = user
},
// 设置当前订单
setCurrentOrder(order) {
state.currentOrder = order
},
// 设置加载状态
setLoading(loading) {
state.loading = loading
},
// 清除状态
clear() {
state.user = null
state.currentOrder = null
state.loading = false
}
}

75
src/utils/constants.js Normal file
View File

@@ -0,0 +1,75 @@
/**
* 常量定义
*/
// 订单状态
export const ORDER_STATUS = {
PENDING: 'PENDING',
SUCCESS: 'SUCCESS',
FAILED: 'FAILED',
REVIEW: 'REVIEW',
CANCELLED: 'CANCELLED'
}
// 订单状态文本
export const ORDER_STATUS_TEXT = {
[ORDER_STATUS.PENDING]: '待支付',
[ORDER_STATUS.SUCCESS]: '支付成功',
[ORDER_STATUS.FAILED]: '支付失败',
[ORDER_STATUS.REVIEW]: '审核中',
[ORDER_STATUS.CANCELLED]: '已取消'
}
// 支付类型
export const PAYMENT_TYPE = {
SALE: 'SALE',
AUTH: 'AUTH'
}
// 支付类型文本
export const PAYMENT_TYPE_TEXT = {
[PAYMENT_TYPE.SALE]: '直接付款',
[PAYMENT_TYPE.AUTH]: '预授权'
}
// 币种
export const CURRENCY = {
USD: 'USD',
EUR: 'EUR',
GBP: 'GBP',
CNY: 'CNY',
JPY: 'JPY'
}
// 币种文本
export const CURRENCY_TEXT = {
[CURRENCY.USD]: '美元',
[CURRENCY.EUR]: '欧元',
[CURRENCY.GBP]: '英镑',
[CURRENCY.CNY]: '人民币',
[CURRENCY.JPY]: '日元'
}
// 响应码
export const RESPONSE_CODE = {
SUCCESS: '0000',
FAIL: '9999',
PARAM_ERROR: '4000',
VALIDATION_ERROR: '4001',
ORDER_NOT_FOUND: '1001',
ORDER_EXISTS: '1002'
}
// API基础路径
export const API_BASE_URL = '/api'
// PingPong SDK URL
export const PINGPONG_SDK_URL = 'https://pay-cdn.pingpongx.com/production/static/sdk/1.2.0/ppPay.min.js'
// PingPong SDK模式
export const PINGPONG_MODE = {
SANDBOX: 'sandbox',
TEST: 'test',
BUILD: 'build'
}

123
src/utils/helpers.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* 工具函数
*/
/**
* 格式化金额
*/
export function formatAmount(amount, currency = 'USD') {
if (!amount) return '0.00'
return parseFloat(amount).toFixed(2)
}
/**
* 格式化日期时间
*/
export function formatDateTime(dateTime, format = 'YYYY-MM-DD HH:mm:ss') {
if (!dateTime) return ''
const date = new Date(dateTime)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 生成订单号
*/
export function generateOrderId() {
const timestamp = Date.now()
const random = Math.floor(Math.random() * 10000)
return `MTN${timestamp}${random.toString().padStart(4, '0')}`
}
/**
* 获取订单状态标签类型
*/
export function getStatusTagType(status) {
if (!status) return 'info'
const statusUpper = status.toUpperCase()
if (statusUpper === 'SUCCESS' || statusUpper === 'SUCCESSFUL') {
return 'success'
} else if (statusUpper === 'FAILED' || statusUpper === 'FAILURE') {
return 'danger'
} else if (statusUpper === 'REVIEW') {
return 'warning'
}
return 'info'
}
/**
* 获取订单状态文本
*/
export function getStatusText(status) {
if (!status) return '未知'
const statusMap = {
'PENDING': '待支付',
'SUCCESS': '支付成功',
'SUCCESSFUL': '支付成功',
'FAILED': '支付失败',
'FAILURE': '支付失败',
'REVIEW': '审核中',
'CANCELLED': '已取消',
'CANCEL': '已取消'
}
return statusMap[status.toUpperCase()] || status
}
/**
* 验证邮箱
*/
export function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return re.test(email)
}
/**
* 验证手机号(简单验证)
*/
export function validatePhone(phone) {
const re = /^1[3-9]\d{9}$/
return re.test(phone)
}
/**
* 防抖函数
*/
export function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
/**
* 节流函数
*/
export function throttle(func, limit) {
let inThrottle
return function(...args) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}

139
src/views/Checkout.vue Normal file
View File

@@ -0,0 +1,139 @@
<template>
<div class="checkout">
<el-card>
<template #header>
<div class="card-header">
<span>PingPong支付收银台</span>
</div>
</template>
<div id="ufo-container" class="checkout-container"></div>
</el-card>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import config from '../config'
const route = useRoute()
const token = route.query.token
onMounted(() => {
if (!token) {
ElMessage.error('缺少支付token请重新创建订单')
return
}
// 动态加载PingPong SDK
loadPingPongSDK()
})
onUnmounted(() => {
// 清理资源
})
const loadPingPongSDK = () => {
// 检查SDK是否已加载
if (window.ppPay) {
initPingPongPay()
return
}
// 加载SDK
const script = document.createElement('script')
script.src = config.pingpongSdkUrl
script.onload = () => {
initPingPongPay()
}
script.onerror = () => {
ElMessage.error('加载支付SDK失败请刷新页面重试')
}
document.head.appendChild(script)
}
const initPingPongPay = () => {
try {
const client = new window.ppPay({
lang: 'zh',
root: '#ufo-container',
manul: false,
located: true,
showPrice: true,
bill: true,
mode: config.pingpongMode, // 根据环境配置sandbox/test/build
menu: false,
base: {
width: '100%',
height: '100%',
fontSize: '14px',
backgroundColor: '#fff',
showHeader: true,
showHeaderLabel: true,
headerLabelFont: '支付',
headerColor: '#333333',
headerSize: '16px',
headerBackgroundColor: '#fff',
headerPadding: '20px',
btnSize: '100%',
btnColor: '#fff',
btnFontSize: '14px',
btnPaddingX: '20px',
btnPaddingY: '10px',
btnBackgroundColor: '#1fa0e8',
btnBorderRadius: '4px',
btnMarginTop: '20px'
}
})
const sdkConfig = {
token: token
}
client.createPayment(sdkConfig)
// 调整容器大小
adjustContainerSize()
window.addEventListener('resize', adjustContainerSize)
} catch (error) {
console.error('初始化支付失败:', error)
ElMessage.error('初始化支付失败,请刷新页面重试')
}
}
const adjustContainerSize = () => {
const container = document.getElementById('ufo-container')
if (!container) return
const winWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth
if (winWidth >= 500) {
const clientW = Math.floor(winWidth / 3)
container.style.width = (clientW >= 500 ? clientW : 500) + 'px'
container.style.margin = '0 auto'
} else {
container.style.width = winWidth + 'px'
}
}
</script>
<style scoped>
.checkout {
max-width: 1200px;
margin: 0 auto;
}
.card-header {
font-size: 18px;
font-weight: bold;
}
.checkout-container {
min-height: 600px;
padding: 20px;
box-sizing: border-box;
}
</style>