feat: refactor member to pay-per-use gravity purchase; mv webview to clipboard+browser
- member.vue: rewrite from subscription plans (free/growth/sprint) to H5-only pay-per-use gravity purchase with quantity selector + QR code - user.vue: gravity card replacing quota card, add share/contribute/H5-buy entry points, plus gravity acquisition modal (share/contribute/buy) - share.vue: layout fix (flex column), smarter copyLink with cached URL, WeChat timeline hint instead of open-type - share.controller.ts: add GET /:shareCode redirect route (IP record + 302) - interview.vue: guest mode fix, H5 buy modal, clipboard copy instead of webview for mini-program - App.vue: handleH5UrlParams for ?token=&buy=gravity auto-login - composables/useGravityPurchase.ts: reusable gravity purchase composable - remove webview.vue (no longer used), replace with clipboard+browser flow - AGENTS.md: sync all above changes, fix duplicate numbering
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { api } from '../config'
|
||||
|
||||
export function useGravityPurchase(onPaymentSuccess?: () => void) {
|
||||
const showQuantityModal = ref(false)
|
||||
const buyQuantity = ref(1)
|
||||
const products = ref<any[]>([])
|
||||
const buyProductType = ref('interview')
|
||||
const payLoading = ref(false)
|
||||
const payError = ref('')
|
||||
const showPayModal = ref(false)
|
||||
const payCodeUrl = ref('')
|
||||
const currentOutTradeNo = ref('')
|
||||
const isMp = ref(false)
|
||||
const paySuccess = ref(false)
|
||||
|
||||
const unitPrice = computed(() => {
|
||||
const p = products.value.find(p => p.type === buyProductType.value)
|
||||
return p?.price || 0
|
||||
})
|
||||
const totalPrice = computed(() => unitPrice.value * buyQuantity.value / 100)
|
||||
const buyGravityPerUnit = computed(() => {
|
||||
const p = products.value.find(p => p.type === buyProductType.value)
|
||||
return p?.gravity || 0
|
||||
})
|
||||
|
||||
const changeQty = (delta: number) => {
|
||||
const next = buyQuantity.value + delta
|
||||
if (next >= 1 && next <= 99) buyQuantity.value = next
|
||||
}
|
||||
const clampQty = () => {
|
||||
if (buyQuantity.value < 1) buyQuantity.value = 1
|
||||
if (buyQuantity.value > 99) buyQuantity.value = 99
|
||||
}
|
||||
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const res = await uni.request({ url: api('/member/plans'), method: 'GET' })
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.products) {
|
||||
const prodList: any[] = []
|
||||
for (const [key, val] of Object.entries(res.data.products as Record<string, any>)) {
|
||||
if (val?.price > 0) prodList.push({ type: key, ...val })
|
||||
}
|
||||
products.value = prodList
|
||||
}
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
const openGravityPurchase = async (productType = 'interview') => {
|
||||
buyProductType.value = productType
|
||||
buyQuantity.value = 1
|
||||
// #ifdef MP-WEIXIN
|
||||
isMp.value = true
|
||||
// #endif
|
||||
// 加载产品信息
|
||||
await loadProducts()
|
||||
showQuantityModal.value = true
|
||||
}
|
||||
|
||||
const cancelPay = () => {
|
||||
showPayModal.value = false
|
||||
payCodeUrl.value = ''
|
||||
payLoading.value = false
|
||||
payError.value = ''
|
||||
}
|
||||
|
||||
const confirmProductBuy = () => {
|
||||
showQuantityModal.value = false
|
||||
startProductPay(buyProductType.value, buyQuantity.value)
|
||||
}
|
||||
|
||||
const startProductPay = async (type: string, quantity = 1) => {
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
if (!token) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
showPayModal.value = true
|
||||
payLoading.value = true
|
||||
payError.value = ''
|
||||
|
||||
if (isMp.value) {
|
||||
try {
|
||||
let res = await uni.request({
|
||||
url: api('/payment/jsapi-product'), method: 'POST',
|
||||
data: { type, quantity },
|
||||
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
timeout: 30000,
|
||||
})
|
||||
payLoading.value = false
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.payParams) {
|
||||
const pp = res.data.payParams as any
|
||||
currentOutTradeNo.value = res.data.outTradeNo || ''
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: pp.timeStamp,
|
||||
nonceStr: pp.nonceStr,
|
||||
package: pp.package,
|
||||
signType: pp.signType || 'RSA',
|
||||
paySign: pp.paySign,
|
||||
success: () => {
|
||||
const no = currentOutTradeNo.value || res.data.outTradeNo
|
||||
pollPayResult(no)
|
||||
},
|
||||
fail: () => {
|
||||
payError.value = '支付未完成'
|
||||
uni.showToast({ title: '支付未完成', icon: 'none' })
|
||||
},
|
||||
})
|
||||
} else if (!res.statusCode || res.statusCode === 0) {
|
||||
payError.value = '网络连接失败,请检查网络后重试'
|
||||
uni.showToast({ title: '网络连接失败', icon: 'none' })
|
||||
} else {
|
||||
const errMsg = res.data?.message || '购买失败'
|
||||
payError.value = errMsg
|
||||
uni.showToast({ title: errMsg, icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api('/payment/create-product'), method: 'POST',
|
||||
data: { type, quantity },
|
||||
header: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
payLoading.value = false
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.codeUrl) {
|
||||
payCodeUrl.value = res.data.codeUrl
|
||||
currentOutTradeNo.value = res.data.outTradeNo
|
||||
// 页面需要自己渲染二维码(依赖 uqrcode)
|
||||
pollPayResult(res.data.outTradeNo)
|
||||
} else if (!res.statusCode || res.statusCode === 0) {
|
||||
payError.value = '网络连接失败,请检查网络后重试'
|
||||
uni.showToast({ title: '网络连接失败', icon: 'none' })
|
||||
} else {
|
||||
payError.value = res.data?.message || '购买失败'
|
||||
uni.showToast({ title: res.data?.message || '购买失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
payLoading.value = false
|
||||
payError.value = '网络错误,请重试'
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pollPayResult = async (outTradeNo: string) => {
|
||||
if (!outTradeNo) return
|
||||
const maxAttempts = 30
|
||||
let attempts = 0
|
||||
const token = uni.getStorageSync('token') || ''
|
||||
|
||||
const poll = async () => {
|
||||
attempts++
|
||||
try {
|
||||
const res = await uni.request({
|
||||
url: api(`/payment/check/${outTradeNo}`), method: 'GET',
|
||||
header: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && res.data?.status === 'success') {
|
||||
paySuccess.value = true
|
||||
showPayModal.value = false
|
||||
uni.showToast({ title: '充值成功!', icon: 'success' })
|
||||
onPaymentSuccess?.()
|
||||
return
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(poll, 2000)
|
||||
} else {
|
||||
payError.value = '支付结果查询超时,请联系客服'
|
||||
uni.showToast({ title: '支付查询超时', icon: 'none' })
|
||||
}
|
||||
}
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
|
||||
return {
|
||||
showQuantityModal, buyQuantity, products, buyProductType,
|
||||
unitPrice, totalPrice, buyGravityPerUnit,
|
||||
payLoading, payError, showPayModal, payCodeUrl, paySuccess,
|
||||
isMp,
|
||||
changeQty, clampQty, loadProducts, openGravityPurchase,
|
||||
confirmProductBuy, cancelPay,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user