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:
yuzhiran
2026-06-20 20:49:15 +08:00
parent a1e1f0b3c3
commit 8ee27fdd32
11 changed files with 648 additions and 593 deletions
@@ -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,
}
}