Files
huihuiSquare/digital-avatar-app/src/views/TokenCharge.vue
T

691 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="token-charge-page">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">积分充值</h1>
<div class="header-right"></div>
</header>
<!-- 当前余额 -->
<section class="balance-section">
<div class="balance-card">
<span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">积分</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
</div>
</section>
<!-- 充值套餐 -->
<section class="plans-section">
<h3 class="section-title">选择充值套餐</h3>
<div class="plan-list">
<div
class="plan-card"
v-for="plan in plans"
:key="plan.id"
:class="{ selected: selectedPlan?.id === plan.id }"
@click="selectedPlan = plan"
>
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
<div class="plan-unit">积分</div>
<div class="plan-price">¥{{ plan.price }}</div>
<div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
</div>
</div>
</section>
<!-- 支付方式 -->
<section class="payment-section">
<h3 class="section-title">支付方式</h3>
<div class="payment-list">
<div
class="payment-card"
:class="{ selected: paymentMethod === 'wechat' }"
@click="paymentMethod = 'wechat'"
>
<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>
<div
class="payment-card"
:class="{ selected: paymentMethod === 'alipay' }"
@click="paymentMethod = 'alipay'"
>
<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 || charging || Boolean(pendingOrderId) }"
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
@click="doCharge"
>
{{ checkoutLabel }}
</button>
</section>
<section v-if="recentOrders.length" class="orders-section">
<h3 class="section-title">充值记录</h3>
<div v-for="order in recentOrders" :key="order.id" class="order-card">
<div>
<strong>{{ order.pointsAmount.toLocaleString() }} 积分</strong>
<p>{{ order.orderNo }} · {{ order.createdAt ? new Date(order.createdAt).toLocaleDateString('zh-CN') : '' }}</p>
</div>
<div class="order-side">
<strong>¥{{ order.price.toFixed(2) }}</strong>
<button v-if="canInvoice(order)" class="text-btn" @click="openInvoice(order)">申请发票</button>
<span v-else class="order-status">{{ orderStatus(order) }}</span>
</div>
</div>
</section>
<div v-if="invoiceOrder" class="modal-mask" @click.self="invoiceOrder = null">
<form class="invoice-modal" @submit.prevent="submitInvoice">
<h3>申请电子发票</h3>
<label>发票类型
<select v-model="invoiceType"><option value="personal">个人</option><option value="company">企业</option></select>
</label>
<label>发票抬头<input v-model.trim="invoiceTitle" maxlength="120" required /></label>
<label v-if="invoiceType === 'company'">企业税号<input v-model.trim="invoiceTaxNumber" minlength="15" maxlength="20" required /></label>
<label>接收邮箱<input v-model.trim="invoiceEmail" type="email" placeholder="选填" /></label>
<p v-if="invoiceError" class="invoice-error">{{ invoiceError }}</p>
<div class="modal-actions"><button type="button" @click="invoiceOrder = null">取消</button><button class="primary" :disabled="invoiceSubmitting">{{ invoiceSubmitting ? '提交中…' : '提交申请' }}</button></div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
chargeToken,
applyTokenInvoice,
getRechargePlans,
getTokenBalance,
getTokenOrders,
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()
// 当前余额
const currentBalance = ref<number>(0)
const totalConsumed = ref<number>(0)
// 充值套餐
const plans = ref<Array<{
id: string
amount: number
price: number
badge?: string
desc?: string
}>>([])
// 选中的套餐
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') || '')
const recentOrders = ref<Array<TokenPaymentOrder & { invoice?: any }>>([])
const invoiceOrder = ref<(TokenPaymentOrder & { invoice?: any }) | null>(null)
const invoiceType = ref<'personal' | 'company'>('personal')
const invoiceTitle = ref('')
const invoiceTaxNumber = ref('')
const invoiceEmail = ref('')
const invoiceError = ref('')
const invoiceSubmitting = ref(false)
let pollTimer: number | undefined
let pollDeadline = 0
let removeNativeListener: (() => void) | undefined
// 从后端加载余额与套餐
const loadData = async () => {
try {
const b: any = await getTokenBalance()
currentBalance.value = b?.balance ?? 0
totalConsumed.value = b?.totalConsumed ?? 0
} catch (e) {
console.error('加载余额失败', e)
}
try {
const p: any = await getRechargePlans()
plans.value = p || []
} catch (e) {
console.error('加载套餐失败', e)
}
try {
const result = await getTokenOrders(1, 10)
recentOrders.value = result?.items || []
} catch (e) {
console.error('加载充值记录失败', e)
}
}
const canInvoice = (order: TokenPaymentOrder & { invoice?: any }) =>
order.status === 'paid' && (!order.refundStatus || order.refundStatus === 'none') &&
(!order.invoice || ['rejected', 'cancelled'].includes(order.invoice.status))
const orderStatus = (order: TokenPaymentOrder & { invoice?: any }) => {
if (order.invoice?.status === 'issued') return '发票已开具'
if (order.invoice?.status === 'pending') return '发票处理中'
if (order.invoice?.status === 'rejected') return '发票已驳回'
return ({ pending: '待支付', paid: '已支付', failed: '支付失败', closed: '已关闭', refunded: '已退款' } as Record<string, string>)[order.status] || order.status
}
const openInvoice = (order: TokenPaymentOrder & { invoice?: any }) => {
invoiceOrder.value = order
invoiceType.value = 'personal'
invoiceTitle.value = ''
invoiceTaxNumber.value = ''
invoiceEmail.value = ''
invoiceError.value = ''
}
const submitInvoice = async () => {
if (!invoiceOrder.value || invoiceSubmitting.value) return
invoiceSubmitting.value = true
invoiceError.value = ''
try {
await applyTokenInvoice(invoiceOrder.value.orderNo, {
title: invoiceTitle.value,
invoiceType: invoiceType.value,
taxNumber: invoiceTaxNumber.value,
email: invoiceEmail.value
})
paymentNoticeTone.value = 'success'
paymentNotice.value = '发票申请已提交,请等待财务处理'
invoiceOrder.value = null
await loadData()
} catch (error: any) {
invoiceError.value = error?.message || '发票申请提交失败'
} finally {
invoiceSubmitting.value = false
}
}
// 会会支付订单创建与到账确认
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 (isInUniWebView()) return 'APP' as const
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'H5' 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()
void loadData()
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 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
}
}
// 返回
const goBack = () => {
router.back()
}
onMounted(() => {
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>
<style scoped>
.token-charge-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
}
/* 顶部导航 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #EDEEF1;
}
.back-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
color: #18191C;
}
.page-title {
font-size: 17px;
font-weight: 600;
margin: 0;
color: #18191C;
}
.header-right {
width: 32px;
}
/* 当前余额 */
.balance-section {
padding: 20px;
}
.balance-card {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
padding: 20px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
border-radius: 16px;
color: white;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
}
.balance-label {
font-size: 14px;
opacity: 0.9;
}
.balance-amount {
font-size: 32px;
font-weight: 700;
}
.balance-unit {
font-size: 14px;
opacity: 0.9;
}
.balance-used {
flex-basis: 100%;
font-size: 12px;
opacity: 0.82;
}
/* 充值套餐 */
.plans-section {
padding: 0 20px 20px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
color: #18191C;
}
.plan-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.plan-card {
position: relative;
padding: 20px 16px;
background: white;
border-radius: 12px;
text-align: center;
border: 2px solid #EDEEF1;
cursor: pointer;
transition: all 0.2s;
}
.plan-card:hover {
border-color: #F97316;
}
.plan-card.selected {
border-color: #F97316;
background: #FFF0E6;
}
.plan-badge {
position: absolute;
top: -1px;
right: -1px;
background: #F97316;
color: white;
padding: 4px 12px;
border-radius: 0 12px 0 12px;
font-size: 11px;
font-weight: 600;
}
.plan-amount {
font-size: 28px;
font-weight: 700;
color: #18191C;
margin-bottom: 4px;
}
.plan-unit {
font-size: 12px;
color: #9398AE;
margin-bottom: 12px;
}
.plan-price {
font-size: 20px;
font-weight: 600;
color: #F97316;
margin-bottom: 8px;
}
.plan-desc {
font-size: 11px;
color: #9398AE;
}
/* 支付方式 */
.payment-section {
padding: 0 20px 20px;
}
.payment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.payment-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: white;
border-radius: 12px;
border: 2px solid #EDEEF1;
cursor: pointer;
transition: all 0.2s;
}
.payment-card:hover {
border-color: #F97316;
}
.payment-card.selected {
border-color: #F97316;
background: #FFF0E6;
}
.payment-icon {
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 {
flex: 1;
font-size: 15px;
font-weight: 500;
color: #18191C;
}
.payment-check {
width: 24px;
height: 24px;
border-radius: 50%;
background: #F97316;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
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;
}
.orders-section { padding: 24px 20px 0; }
.order-card { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; margin-bottom:10px; background:#fff; border:1px solid #EDEEF1; border-radius:12px; }
.order-card strong { color:#18191C; font-size:14px; }.order-card p,.order-status { color:#9398AE; font-size:11px; margin:5px 0 0; }
.order-side { text-align:right; }.text-btn { display:block; margin-top:5px; padding:0; border:0; background:none; color:#F97316; font-size:12px; cursor:pointer; }
.modal-mask { position:fixed; inset:0; z-index:20; display:grid; place-items:center; padding:20px; background:rgba(15,23,42,.45); }
.invoice-modal { width:min(100%,420px); padding:22px; border-radius:16px; background:#fff; box-shadow:0 18px 50px rgba(15,23,42,.2); }
.invoice-modal h3 { margin:0 0 18px; }.invoice-modal label { display:grid; gap:7px; margin:12px 0; color:#4B5563; font-size:13px; }
.invoice-modal input,.invoice-modal select { width:100%; height:42px; padding:0 12px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; color:#18191C; font-size:14px; }
.invoice-error { color:#B42318; font-size:12px; }.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; }
.modal-actions button { padding:9px 18px; border:1px solid #D9DCE3; border-radius:9px; background:#fff; }.modal-actions .primary { border-color:#F97316; background:#F97316; color:#fff; }
.checkout-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
border: none;
border-radius: 16px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
transition: all 0.2s;
}
.checkout-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(249, 115, 22, 0.4);
}
.checkout-btn.disabled {
background: #D1D5DB;
box-shadow: none;
cursor: not-allowed;
}
.checkout-btn.disabled:hover {
transform: none;
}
</style>