feat: 更新支付模块 (Stripe/PayPal/PingPong) 和 uni-app 配置

This commit is contained in:
TradeMate Dev
2026-06-16 13:32:50 +08:00
parent e5b1e7d588
commit 15d172e825
17 changed files with 1254 additions and 12 deletions
+279
View File
@@ -0,0 +1,279 @@
<template>
<view class="page">
<view class="balance-card">
<text class="balance-label">可用次数</text>
<text class="balance-value">{{ balance }}</text>
<text class="balance-tip" v-if="subscription">含订阅 {{ subCredits }}/</text>
</view>
<view class="section">
<view class="tabs">
<text class="tab" :class="{ active: tab === 'packages' }" @click="tab = 'packages'">次数包</text>
<text class="tab" :class="{ active: tab === 'plans' }" @click="tab = 'plans'">订阅方案</text>
<text class="tab" :class="{ active: tab === 'history' }" @click="tab = 'history'">消费记录</text>
</view>
<view v-if="tab === 'packages'">
<view class="item-card" v-for="pkg in packages" :key="pkg.id">
<view class="item-info">
<text class="item-name">{{ pkg.name }}</text>
<text class="item-credits">{{ pkg.credits }} </text>
</view>
<view class="item-action">
<text class="item-price">¥{{ pkg.price }}</text>
<view class="buy-btns">
<text class="buy-btn" @click="purchase(pkg.id)">微信</text>
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'stripe')">Card</text>
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'paypal')">PayPal</text>
<text class="buy-btn pingpong" @click="overseasPurchase(pkg.id, 'pingpong')">Card</text>
</view>
</view>
</view>
</view>
<view v-if="tab === 'plans'">
<view class="item-card" v-for="plan in subPlans" :key="plan.id"
:class="{ active: plan.id === currentSubId }">
<view class="item-info">
<text class="item-name">{{ plan.name }}</text>
<text class="item-credits">{{ plan.credits_per_month }} /</text>
</view>
<view class="item-action">
<text class="item-price">¥{{ plan.price }}/</text>
<text class="buy-btn" v-if="plan.id === currentSubId" @click="cancelSub">取消订阅</text>
<text class="buy-btn" v-else @click="subscribe(plan.id)">订阅</text>
</view>
</view>
</view>
<view v-if="tab === 'history'">
<view class="history-item" v-for="item in history" :key="item.id">
<text class="hist-desc">{{ item.description || item.action }}</text>
<text class="hist-amount" :class="{ deduct: item.amount < 0 }">
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
</text>
<text class="hist-date">{{ formatDate(item.created_at) }}</text>
</view>
<text class="empty" v-if="history.length === 0">暂无记录</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { creditApi, paymentApi } from '@/utils/api.js'
const tab = ref('packages')
const balance = ref(0)
const packages = ref([])
const subPlans = ref([])
const history = ref([])
const subscription = ref(null)
const currentSubId = ref('')
const subCredits = ref(0)
const handlePayPalRedirect = async () => {
const params = new URLSearchParams(window.location.search)
const gateway = params.get('gateway')
const token = params.get('token')
const orderId = params.get('order_id')
const result = params.get('result')
if (gateway === 'paypal' && result === 'success' && token && orderId) {
try {
await creditApi.paypalCapture(orderId, token)
uni.showToast({ title: '支付成功', icon: 'success' })
} catch (e) {
uni.showToast({ title: e.message || '支付处理失败', icon: 'none' })
}
const url = new URL(window.location.href)
url.search = ''
window.history.replaceState({}, '', url)
loadData()
}
}
const loadData = async () => {
try {
const cb = await creditApi.balance()
balance.value = cb.balance || 0
subscription.value = cb.subscription || null
if (subscription.value) {
currentSubId.value = subscription.value.plan_id
subCredits.value = subscription.value.credits_per_month || 0
}
} catch {}
try {
const pkgs = await creditApi.packages()
packages.value = pkgs || []
} catch {}
try {
const plans = await creditApi.subscriptionPlans()
subPlans.value = plans || []
} catch {}
try {
const h = await creditApi.history()
history.value = h.items || h || []
} catch {}
}
const purchase = async (packageId) => {
uni.showLoading({ title: '创建订单...' })
try {
const res = await creditApi.purchase(packageId, 'jsapi')
if (res.pay_params) {
uni.requestPayment({
provider: 'wxpay',
timeStamp: res.pay_params.timeStamp,
nonceStr: res.pay_params.nonceStr,
package: res.pay_params.package,
signType: res.pay_params.signType,
paySign: res.pay_params.paySign,
success: () => {
uni.showToast({ title: '购买成功', icon: 'success' })
loadData()
},
fail: () => {
uni.showToast({ title: '支付取消', icon: 'none' })
},
})
} else if (res.pay_url) {
window.location.href = res.pay_url
} else {
uni.showToast({ title: '订单创建成功', icon: 'success' })
loadData()
}
} catch (e) {
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
const overseasPurchase = async (packageId, gateway) => {
uni.showLoading({ title: '跳转支付...' })
try {
const baseUrl = 'https://trade.yuzhiran.com/workspace/credits'
const res = await creditApi.stripePurchase(packageId, gateway, `${baseUrl}?gateway=${gateway}&result=success`, `${baseUrl}?gateway=${gateway}&result=cancel`)
if (res.session_url) {
window.location.href = res.session_url
} else {
uni.showToast({ title: '创建订单失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: e.message || '下单失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
const subscribe = async (planId) => {
uni.showLoading({ title: '处理中...' })
try {
const res = await paymentApi.createOrder(planId, 'jsapi')
if (res.pay_params) {
uni.requestPayment({
provider: 'wxpay',
timeStamp: res.pay_params.timeStamp,
nonceStr: res.pay_params.nonceStr,
package: res.pay_params.package,
signType: res.pay_params.signType,
paySign: res.pay_params.paySign,
success: () => {
uni.showToast({ title: '订阅成功', icon: 'success' })
loadData()
},
fail: () => {
uni.showToast({ title: '支付取消', icon: 'none' })
},
})
} else if (res.pay_url) {
window.location.href = res.pay_url
} else {
uni.showToast({ title: '订阅成功', icon: 'success' })
loadData()
}
} catch (e) {
uni.showToast({ title: e.message || '订阅失败', icon: 'none' })
} finally {
uni.hideLoading()
}
}
const cancelSub = async () => {
uni.showModal({
title: '提示',
content: '确定取消订阅?',
success: async (res) => {
if (res.confirm) {
try {
await creditApi.cancelSubscription()
uni.showToast({ title: '已取消', icon: 'success' })
loadData()
} catch (e) {
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
}
}
},
})
}
const formatDate = (d) => {
if (!d) return ''
const date = new Date(d)
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
}
onMounted(loadData)
</script>
<style scoped>
.page { padding: 20rpx; background: #f5f5f5; min-height: 100vh; }
.balance-card {
background: linear-gradient(135deg, #1890ff, #096dd9);
border-radius: 16rpx;
padding: 40rpx;
text-align: center;
margin-bottom: 20rpx;
}
.balance-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; }
.balance-value { font-size: 72rpx; color: #fff; font-weight: bold; display: block; margin: 10rpx 0; }
.balance-tip { font-size: 24rpx; color: rgba(255,255,255,0.7); display: block; }
.section { background: #fff; border-radius: 16rpx; overflow: hidden; }
.tabs { display: flex; border-bottom: 2rpx solid #f0f0f0; }
.tab {
flex: 1; text-align: center; padding: 24rpx 0;
font-size: 28rpx; color: #666; position: relative;
}
.tab.active { color: #1890ff; font-weight: 600; }
.tab.active::after {
content: ''; position: absolute; bottom: 0; left: 20%; right: 20%;
height: 4rpx; background: #1890ff; border-radius: 2rpx;
}
.item-card {
display: flex; justify-content: space-between; align-items: center;
padding: 28rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
}
.item-card.active { background: #f0f9ff; }
.item-info { display: flex; flex-direction: column; }
.item-name { font-size: 30rpx; font-weight: 500; color: #333; }
.item-credits { font-size: 24rpx; color: #999; margin-top: 6rpx; }
.item-action { display: flex; flex-direction: column; align-items: flex-end; }
.item-price { font-size: 32rpx; color: #f5222d; font-weight: bold; }
.buy-btn {
font-size: 24rpx; color: #1890ff; margin-top: 8rpx; padding: 4rpx 16rpx;
border: 2rpx solid #1890ff; border-radius: 8rpx;
}
.buy-btns { display: flex; gap: 8rpx; margin-top: 8rpx; }
.buy-btn.overseas { color: #52c41a; border-color: #52c41a; }
.buy-btn.pingpong { color: #722ed1; border-color: #722ed1; }
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 24rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
}
.hist-desc { flex: 1; font-size: 26rpx; color: #333; }
.hist-amount { font-size: 28rpx; font-weight: bold; color: #52c41a; margin-left: 16rpx; }
.hist-amount.deduct { color: #f5222d; }
.hist-date { font-size: 22rpx; color: #999; margin-left: 16rpx; min-width: 100rpx; text-align: right; }
.empty { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
</style>