8ee27fdd32
- 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
194 lines
6.5 KiB
TypeScript
194 lines
6.5 KiB
TypeScript
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,
|
|
}
|
|
}
|