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
+1
View File
@@ -18,6 +18,7 @@ export const PAGES = {
LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade',
CREDITS: '/pages/credits/credits',
FEEDBACK: '/pages/feedback/feedback',
FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification',
+6
View File
@@ -91,6 +91,12 @@
"navigationBarTitleText": "升级会员"
}
},
{
"path": "pages/credits/credits",
"style": {
"navigationBarTitleText": "购买次数"
}
},
{
"path": "pages/followup/followup",
"style": {
+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>
+36 -4
View File
@@ -9,6 +9,16 @@
</view>
</view>
<view class="credit-card" v-if="user.tier !== 'guest'" @click="goCredits">
<view class="credit-left">
<text class="credit-label">可用次数</text>
<text class="credit-value">{{ creditBalance }}</text>
</view>
<view class="credit-right">
<text class="credit-buy">购买次数 </text>
</view>
</view>
<view class="section">
<view class="section-title">账号设置</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
@@ -21,9 +31,9 @@
<text class="menu-text">修改密码</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goUpgrade">
<view class="menu-item" @click="goCredits">
<text class="menu-icon"></text>
<text class="menu-text">会员升级</text>
<text class="menu-text">购买次数</text>
<text class="menu-arrow"></text>
</view>
</view>
@@ -128,11 +138,12 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi } from '@/utils/api.js'
import { authApi, creditApi } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
const user = ref({})
const creditBalance = ref(0)
const showProfileEdit = ref(false)
const showPassword = ref(false)
const editForm = ref({ username: '', email: '' })
@@ -158,6 +169,12 @@ const loadUser = async () => {
} catch {
user.value = { tier: 'guest' }
}
try {
const cb = await creditApi.balance()
creditBalance.value = cb.balance || 0
} catch {
creditBalance.value = 0
}
}
const saveProfile = async () => {
@@ -195,7 +212,7 @@ const changePwd = async () => {
}
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
const goCredits = () => uni.navigateTo({ url: PAGES.CREDITS })
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
@@ -280,6 +297,21 @@ onShow(loadUser)
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
.tier-badge.guest { background: #fce4ec; color: #c62828; }
.credit-card {
background: linear-gradient(135deg, #f97316, #ea580c);
border-radius: 16rpx;
padding: 28rpx 32rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.credit-left { display: flex; flex-direction: column; }
.credit-label { font-size: 24rpx; color: rgba(255,255,255,0.8); }
.credit-value { font-size: 48rpx; color: #fff; font-weight: bold; }
.credit-right { }
.credit-buy { font-size: 28rpx; color: #fff; font-weight: 500; }
.section {
background: #fff;
border-radius: 16rpx;
+14
View File
@@ -259,6 +259,20 @@ export const paymentApi = {
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
}
export const creditApi = {
balance: () => request('/credits/balance'),
history: (page = 1, size = 20) => request(`/credits/history?page=${page}&size=${size}`),
packages: () => request('/credits/packages'),
subscriptionPlans: () => request('/credits/subscription-plans'),
purchase: (packageId, payType = 'alipay') =>
request('/credits/purchase', 'POST', { package_id: packageId, pay_type: payType }),
stripePurchase: (packageId, gateway = 'stripe', successUrl, cancelUrl) =>
request('/credits/stripe-purchase', 'POST', { package_id: packageId, gateway: gateway, success_url: successUrl, cancel_url: cancelUrl }),
cancelSubscription: () => request('/credits/cancel-subscription', 'POST'),
paypalCapture: (orderNo, token) =>
request('/payment/paypal-capture', 'POST', { order_no: orderNo, token: token }),
}
export const feedbackApi = {
submit: (content, category = 'general', contact = '') =>
request('/feedback', 'POST', { content, category, contact }),