feat(avatar): integrate Huihui payments
This commit is contained in:
@@ -146,6 +146,22 @@ export interface TokenUsageSummary {
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
export interface TokenPaymentOrder {
|
||||
id: string
|
||||
orderNo: string
|
||||
planId: string
|
||||
paymentMethod: 'wechat' | 'alipay'
|
||||
payType: 'WECHAT' | 'ALIPAY'
|
||||
payWay: 'APP' | 'LITE' | 'JSAPI'
|
||||
pointsAmount: number
|
||||
price: number
|
||||
status: 'pending' | 'paid' | 'failed'
|
||||
providerStatus: string
|
||||
payMessage: string
|
||||
failureReason: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
// 获取 Token 余额
|
||||
export const getTokenBalance = () =>
|
||||
request.get<TokenBalance>('/token/balance')
|
||||
@@ -154,9 +170,15 @@ export const getTokenBalance = () =>
|
||||
export const getRechargePlans = () =>
|
||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||
|
||||
// 执行充值
|
||||
export const chargeToken = (planId: string) =>
|
||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
||||
// 创建会会支付订单;支付回调成功后积分到账。
|
||||
export const chargeToken = (
|
||||
planId: string,
|
||||
paymentMethod: 'wechat' | 'alipay',
|
||||
payScene: 'APP' | 'LITE' | 'JSAPI'
|
||||
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||
|
||||
export const getTokenPaymentStatus = (orderId: string) =>
|
||||
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||
|
||||
// 按分身和使用场景汇总 Token 消耗
|
||||
export const getTokenUsage = () =>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>支付宝</title>
|
||||
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,4 @@
|
||||
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>微信支付</title>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -4,6 +4,7 @@
|
||||
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||
|
||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||
const nativeMessageHandlers = new Set<(message: any) => void>()
|
||||
|
||||
export interface UniLaunchParams {
|
||||
token?: string
|
||||
@@ -25,7 +26,21 @@ function readParams(search: string, target: UniLaunchParams): void {
|
||||
|
||||
// 是否运行在 uniapp web-view 环境中
|
||||
export function isInUniWebView(): boolean {
|
||||
return !!(window as any).uni?.webView
|
||||
const runtime = window as any
|
||||
const userAgent = navigator.userAgent
|
||||
const isDCloudApp = Boolean(
|
||||
runtime.plus ||
|
||||
runtime.__dcloud_weex_postMessage ||
|
||||
runtime.__dcloud_weex_ ||
|
||||
/uni-app|Html5Plus/i.test(userAgent)
|
||||
)
|
||||
const isMiniProgram = Boolean(
|
||||
(/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) ||
|
||||
runtime.my ||
|
||||
runtime.swan?.webView ||
|
||||
runtime.tt?.miniProgram
|
||||
)
|
||||
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
|
||||
}
|
||||
|
||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||
@@ -65,14 +80,18 @@ export function postToNative(message: Record<string, any>): boolean {
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
||||
export function onNativeMessage(handler: (message: any) => void): void {
|
||||
export function onNativeMessage(handler: (message: any) => void): () => void {
|
||||
nativeMessageHandlers.add(handler)
|
||||
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||
try {
|
||||
handler(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
for (const listener of nativeMessageHandlers) {
|
||||
try {
|
||||
listener(message)
|
||||
} catch (e) {
|
||||
console.error('[uniBridge] handler error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => nativeMessageHandlers.delete(handler)
|
||||
}
|
||||
|
||||
// 便捷事件
|
||||
@@ -81,5 +100,6 @@ export const UniEvents = {
|
||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
|
||||
back: () => postToNative({ type: 'back' })
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
:class="{ selected: paymentMethod === 'wechat' }"
|
||||
@click="paymentMethod = 'wechat'"
|
||||
>
|
||||
<span class="payment-icon">💚</span>
|
||||
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
|
||||
<span class="payment-name">微信支付</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||
</div>
|
||||
@@ -55,31 +55,41 @@
|
||||
:class="{ selected: paymentMethod === 'alipay' }"
|
||||
@click="paymentMethod = 'alipay'"
|
||||
>
|
||||
<span class="payment-icon">💙</span>
|
||||
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
|
||||
<span class="payment-name">支付宝</span>
|
||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
||||
</section>
|
||||
|
||||
<!-- 充值按钮 -->
|
||||
<section class="checkout-section">
|
||||
<button
|
||||
class="checkout-btn"
|
||||
:class="{ disabled: !selectedPlan }"
|
||||
:disabled="!selectedPlan"
|
||||
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
|
||||
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
|
||||
@click="doCharge"
|
||||
>
|
||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
||||
{{ checkoutLabel }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
||||
import {
|
||||
chargeToken,
|
||||
getRechargePlans,
|
||||
getTokenBalance,
|
||||
getTokenPaymentStatus,
|
||||
type TokenPaymentOrder
|
||||
} from '@/api'
|
||||
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||
import wechatPayIcon from '@/assets/wechat-pay.svg'
|
||||
import alipayIcon from '@/assets/alipay.svg'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -101,6 +111,12 @@ const selectedPlan = ref<any>(null)
|
||||
|
||||
// 支付方式
|
||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||
const paymentNotice = ref('')
|
||||
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
||||
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
|
||||
let pollTimer: number | undefined
|
||||
let pollDeadline = 0
|
||||
let removeNativeListener: (() => void) | undefined
|
||||
|
||||
// 从后端加载余额与套餐
|
||||
const loadData = async () => {
|
||||
@@ -119,23 +135,144 @@ const loadData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 执行充值(写入后端)
|
||||
// 会会支付订单创建与到账确认
|
||||
const charging = ref(false)
|
||||
const formatTokenAmount = (amount: number) => {
|
||||
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
||||
return amount.toLocaleString()
|
||||
}
|
||||
const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分')
|
||||
|
||||
const checkoutLabel = computed(() => {
|
||||
if (!selectedPlan.value) return '请选择充值套餐'
|
||||
if (charging.value) return '正在创建支付订单…'
|
||||
if (pendingOrderId.value) return '等待支付结果…'
|
||||
return `立即支付 ¥${selectedPlan.value.price}`
|
||||
})
|
||||
|
||||
const payScene = () => {
|
||||
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||
return 'APP' as const
|
||||
}
|
||||
|
||||
const parsePayMessage = (message: string) => {
|
||||
const text = (message || '').trim()
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
const findPaymentUrl = (value: any): string => {
|
||||
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
|
||||
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
|
||||
const invoke = () => {
|
||||
const bridge = (window as any).WeixinJSBridge
|
||||
if (!bridge?.invoke) {
|
||||
reject(new Error('当前微信环境无法拉起支付'))
|
||||
return
|
||||
}
|
||||
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
|
||||
const message = String(result?.err_msg || result?.errMsg || '')
|
||||
if (/\bok\b/i.test(message)) resolve()
|
||||
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
|
||||
})
|
||||
}
|
||||
if ((window as any).WeixinJSBridge) invoke()
|
||||
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
||||
})
|
||||
|
||||
const launchPayment = async (order: TokenPaymentOrder) => {
|
||||
const parsed = parsePayMessage(order.payMessage)
|
||||
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
|
||||
await invokeWechatJsapi(parsed)
|
||||
return
|
||||
}
|
||||
if (isInUniWebView() && UniEvents.payment({
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
paymentMethod: order.paymentMethod,
|
||||
payType: order.payType,
|
||||
payWay: order.payWay,
|
||||
payMessage: order.payMessage,
|
||||
paymentParams: parsed
|
||||
})) return
|
||||
|
||||
const paymentUrl = findPaymentUrl(parsed)
|
||||
if (paymentUrl) {
|
||||
window.location.assign(paymentUrl)
|
||||
return
|
||||
}
|
||||
throw new Error('请在会会 App 内完成支付')
|
||||
}
|
||||
|
||||
const clearPendingOrder = () => {
|
||||
pendingOrderId.value = ''
|
||||
sessionStorage.removeItem('hh_pending_payment_order')
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
|
||||
const pollPayment = async () => {
|
||||
if (!pendingOrderId.value) return
|
||||
try {
|
||||
const order = await getTokenPaymentStatus(pendingOrderId.value)
|
||||
if (order.status === 'paid') {
|
||||
currentBalance.value = order.balance
|
||||
paymentNoticeTone.value = 'success'
|
||||
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
||||
clearPendingOrder()
|
||||
return
|
||||
}
|
||||
if (order.status === 'failed') {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
|
||||
clearPendingOrder()
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询支付结果失败', error)
|
||||
}
|
||||
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
|
||||
else {
|
||||
paymentNoticeTone.value = 'pending'
|
||||
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
|
||||
clearPendingOrder()
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = (orderId: string) => {
|
||||
pendingOrderId.value = orderId
|
||||
sessionStorage.setItem('hh_pending_payment_order', orderId)
|
||||
pollDeadline = Date.now() + 2 * 60 * 1000
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
void pollPayment()
|
||||
}
|
||||
|
||||
const doCharge = async () => {
|
||||
if (!selectedPlan.value || charging.value) return
|
||||
charging.value = true
|
||||
paymentNotice.value = ''
|
||||
try {
|
||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
||||
const res: any = await chargeToken(selectedPlan.value.id)
|
||||
currentBalance.value = res?.balance ?? currentBalance.value
|
||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} 积分`)
|
||||
} catch (e) {
|
||||
alert('充值失败,请重试')
|
||||
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
|
||||
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
||||
paymentNoticeTone.value = 'pending'
|
||||
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
||||
startPolling(order.id)
|
||||
await launchPayment(order)
|
||||
} catch (error: any) {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||
clearPendingOrder()
|
||||
} finally {
|
||||
charging.value = false
|
||||
}
|
||||
@@ -147,7 +284,33 @@ const goBack = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
void loadData()
|
||||
removeNativeListener = onNativeMessage((message) => {
|
||||
if (message?.type !== 'paymentResult') return
|
||||
if (message.orderId && message.orderId !== pendingOrderId.value) return
|
||||
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = '支付已取消'
|
||||
clearPendingOrder()
|
||||
return
|
||||
}
|
||||
if (String(message.status).toLowerCase() === 'failed') {
|
||||
paymentNoticeTone.value = 'error'
|
||||
paymentNotice.value = message.message || '支付未完成,请重新发起'
|
||||
clearPendingOrder()
|
||||
return
|
||||
}
|
||||
void pollPayment()
|
||||
})
|
||||
if (pendingOrderId.value) {
|
||||
paymentNotice.value = '正在确认上一笔支付结果'
|
||||
startPolling(pendingOrderId.value)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) window.clearTimeout(pollTimer)
|
||||
removeNativeListener?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -334,7 +497,24 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.payment-icon {
|
||||
font-size: 24px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.payment-icon img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
background: #EAF9F0;
|
||||
}
|
||||
|
||||
.alipay-icon {
|
||||
background: #EAF3FF;
|
||||
}
|
||||
|
||||
.payment-name {
|
||||
@@ -357,6 +537,26 @@ onMounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payment-notice {
|
||||
margin: 12px 2px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: #9A5B16;
|
||||
background: #FFF5E8;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.payment-notice.success {
|
||||
color: #08783E;
|
||||
background: #EAF9F0;
|
||||
}
|
||||
|
||||
.payment-notice.error {
|
||||
color: #B42318;
|
||||
background: #FFF0EE;
|
||||
}
|
||||
|
||||
/* 充值按钮 */
|
||||
.checkout-section {
|
||||
padding: 0 20px;
|
||||
|
||||
Reference in New Issue
Block a user