feat(payment): 集成PingPong支付SDK并实现支付功能
- 添加PingPong支付SDK动态加载逻辑 - 实现支付组件与SDK的初始化配置 - 配置支付容器自适应不同屏幕尺寸 - 添加支付token校验和错误提示 - 集成Element Plus消息组件显示支付状态 - 配置SDK基础样式和按钮样式参数 - 添加支付页面路由和基本布局结构 - 实现支付结果页面跳转逻辑 - 添加订单状态管理和响应码常量定义 - 集成工具函数支持金额格式化和日期处理 - 配置开发环境变量支持沙箱模式切换 - 添加防抖节流等常用工具函数实现 - 实现订单号生成和状态文本映射逻辑 - 添加表单验证函数支持邮箱和手机校验
This commit is contained in:
64
src/App.vue
Normal file
64
src/App.vue
Normal 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
24
src/config/index.js
Normal 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
36
src/router/index.js
Normal 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
42
src/store/index.js
Normal 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
75
src/utils/constants.js
Normal 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
123
src/utils/helpers.js
Normal 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
139
src/views/Checkout.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user