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

538 lines
14 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">
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
</section>
<!-- 充值按钮 -->
<section class="checkout-section">
<button
class="checkout-btn"
:class="{ disabled: !selectedPlan || charging }"
:disabled="!selectedPlan || charging"
@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, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
chargeTokenOrg,
applyTokenInvoice,
getRechargePlans,
getTokenBalance,
getTokenOrders,
type TokenPaymentOrder
} from '@/api'
import { isInUniWebView } from '@/utils/uniapp-bridge'
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 paymentNotice = ref('')
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
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)
// 从后端加载余额与套餐
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 '正在创建支付订单…'
return '去支付'
})
const payScene = () => {
if (/miniProgram/i.test(navigator.userAgent)) return 'LITE' as const
if (isInUniWebView()) return 'APP' as const
if (/MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
return 'H5' as const
}
const doCharge = async () => {
if (!selectedPlan.value || charging.value) return
charging.value = true
paymentNotice.value = ''
try {
const order = await chargeTokenOrg(selectedPlan.value.id, 'wechat', payScene())
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
if (!order.providerOrderNo || !/^\d+$/.test(order.providerOrderNo)) throw new Error('会会充值订单号缺失')
paymentNoticeTone.value = 'pending'
paymentNotice.value = '支付订单已创建,支付结果将由会会回调确认'
const checkoutBase = window.location.hostname === 'digital.99hui.com'
? 'https://99hui.com/huihui-h5/'
: 'https://fat-open.99hui.com/huihui-h5/'
const evn = sessionStorage.getItem('hh_launch_evn') || 'wx'
const evnPart = `&evn=${encodeURIComponent(evn)}`
const tokenPart = order.checkoutToken
? `&token=${encodeURIComponent(order.checkoutToken)}`
: ''
window.location.assign(`${checkoutBase}#/payOrder?orderNo=${encodeURIComponent(order.providerOrderNo)}&isOrg=true${evnPart}${tokenPart}`)
} catch (error: any) {
paymentNoticeTone.value = 'error'
paymentNotice.value = error?.message || '充值失败,请重试'
} finally {
charging.value = false
}
}
// 返回
const goBack = () => {
router.back()
}
onMounted(() => {
void loadData()
})
</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>