feat(avatar): integrate Huihui payments

This commit is contained in:
stefanfeng
2026-08-26 14:41:21 +08:00
parent 5360cac8ad
commit c37294be17
13 changed files with 962 additions and 39 deletions
+25 -3
View File
@@ -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 = () =>
+4
View File
@@ -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

+26 -6
View File
@@ -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' })
}
+216 -16
View File
@@ -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;