Files
Aether/frontend/src/views/user/WalletCenter.vue
T
elky a90d564931 fix: restore security hardening compatibility and validation
Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks.
2026-09-07 21:14:27 +08:00

1344 lines
47 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="space-y-6 pb-8">
<div
v-if="loadingInitial"
class="py-16"
>
<LoadingState message="正在加载钱包数据..." />
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
总可用额度
</div>
<div class="text-3xl font-bold tabular-nums">
{{ walletBalance?.unlimited ? '无限制' : formatCurrency(totalAvailableBalance) }}
</div>
<div class="text-xs text-muted-foreground">
套餐额度: {{ formatCurrency(packageBalance) }} · 钱包余额: {{ formatCurrency(walletOnlyBalance) }}
</div>
</Card>
<Card class="p-5 space-y-3">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
套餐今日额度
</div>
<div class="text-2xl font-bold tabular-nums">
<template v-if="hasActiveDailyQuota">
{{ formatCurrency(packageBalance) }}
</template>
<template v-else>
未开通
</template>
</div>
<div
v-if="hasActiveDailyQuota"
class="space-y-1.5"
>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all"
:style="{ width: `${dailyQuotaRemainingPercent}%` }"
/>
</div>
<div class="text-xs text-muted-foreground">
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
</div>
<div class="text-xs text-muted-foreground">
{{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
</div>
</div>
<div
v-else
class="text-xs text-muted-foreground"
>
开通每日额度套餐后会优先消耗这里的额度。
</div>
</Card>
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
钱包余额
</div>
<div class="text-2xl font-semibold tabular-nums">
{{ formatCurrency(walletOnlyBalance) }}
</div>
<div class="text-xs text-muted-foreground">
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
</div>
</Card>
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
钱包状态
</div>
<div class="flex items-center gap-2">
<Badge :variant="walletStatusBadge(walletBalance?.wallet?.status)">
{{ walletStatusLabel(walletBalance?.wallet?.status) }}
</Badge>
</div>
<div class="text-xs text-muted-foreground">
累计充值 / 消费:
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
<span class="text-muted-foreground font-normal mx-1">/</span>
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
</div>
<div class="text-xs text-muted-foreground">
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
</div>
<div
v-if="walletBalance?.unlimited"
class="text-xs text-amber-600 dark:text-amber-400"
>
当前账号处于无限制模式,余额仅用于账务统计。
</div>
<div class="text-xs text-muted-foreground">
待处理退款: {{ walletBalance?.pending_refund_count || 0 }}
</div>
</Card>
</div>
<Card class="p-5 space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold">
兑换码充值
</h3>
<p class="text-xs text-muted-foreground mt-1">
输入卡密后会直接充值到钱包的充值余额
</p>
</div>
<RefreshButton
:loading="loadingOrders || loadingTransactions"
@click="refreshWallet"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-3">
<Input
v-model="redeemForm.code"
placeholder="输入兑换码,例如 ABCD-EFGH-IJKL-MNOP"
autocomplete="off"
/>
<Button
:disabled="submittingRedeem"
@click="submitRedeem"
>
{{ submittingRedeem ? '兑换中...' : '立即兑换' }}
</Button>
</div>
<div
v-if="latestRedeem"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground space-y-1.5"
>
<div>
已兑换批次: <span class="font-medium text-foreground">{{ latestRedeem.batch_name }}</span>
</div>
<div>
充值金额: <span class="font-medium text-foreground">{{ formatCurrency(latestRedeem.amount_usd) }}</span>
</div>
<div>
关联订单: <span class="font-mono text-foreground">{{ latestRedeem.order.order_no }}</span>
</div>
</div>
</Card>
<!-- TODO(wallet): 充值/退款用户主动操作入口暂未启用,待支付链路联调完成后再开放 -->
<div
v-if="ENABLE_WALLET_ACTION_FORMS"
class="grid grid-cols-1 xl:grid-cols-2 gap-4"
>
<Card class="p-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
发起充值
</h3>
<RefreshButton
:loading="loadingOrders"
@click="loadOrders"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label>充值金额 (USD)</Label>
<Input
v-model.number="rechargeForm.amount_usd"
type="number"
min="0.01"
step="0.01"
placeholder="10"
/>
</div>
<div class="space-y-1.5">
<Label>支付方式</Label>
<Select v-model="rechargeForm.payment_option_key">
<SelectTrigger>
<SelectValue
:placeholder="rechargeOptionsWithKey.length ? '选择支付方式' : '暂无可用支付方式'"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in rechargeOptionsWithKey"
:key="option.key"
:value="option.key"
>
{{ option.display_name }}
<span
v-if="option.pay_currency && option.usd_exchange_rate"
class="text-xs text-muted-foreground"
>
· {{ option.pay_currency }}
</span>
<span
v-if="Number(option.fee_rate || 0) > 0"
class="text-xs text-muted-foreground"
>
· 手续费 {{ Number(option.fee_rate || 0).toFixed(2) }}%
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div
v-if="selectedRechargeOption?.usd_exchange_rate"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
>
预计支付:
<span class="font-medium text-foreground">
{{ estimatedRechargePayAmount }}
{{ rechargePayCurrency }}
</span>
· 1 USD = {{ Number(selectedRechargeOption.usd_exchange_rate).toFixed(4) }}
{{ rechargePayCurrency }}
<template v-if="estimatedRechargeFeeAmount > 0">
· 手续费 {{ estimatedRechargeFeeAmount.toFixed(2) }} {{ rechargePayCurrency }}
({{ estimatedRechargeFeeRate.toFixed(2) }}%)
</template>
</div>
<Button
class="w-full"
:disabled="submittingRecharge || rechargeOptionsWithKey.length === 0"
@click="submitRecharge"
>
{{ submittingRecharge ? '创建订单中...' : '创建充值订单' }}
</Button>
<div
v-if="latestRecharge"
class="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-1.5"
>
<div class="text-xs text-muted-foreground">
最新订单: <span class="font-medium text-foreground">{{ latestRecharge.order.order_no }}</span>
</div>
<div class="text-xs text-muted-foreground">
状态:
<Badge
:variant="paymentStatusBadge(latestRecharge.order.status)"
class="ml-1"
>
{{ paymentStatusLabel(latestRecharge.order.status) }}
</Badge>
</div>
<a
v-if="latestRechargePaymentUrl"
class="inline-flex text-xs text-primary hover:underline"
:href="latestRechargePaymentUrl"
target="_blank"
rel="noopener noreferrer"
@click.prevent="submitPaymentInstructions(latestRecharge.payment_instructions)"
>
打开支付链接
</a>
<button
v-if="latestRechargeStripeInstructions"
type="button"
class="inline-flex text-xs text-primary hover:underline"
@click="submitPaymentInstructions(latestRecharge?.payment_instructions)"
>
打开 Stripe 支付
</button>
<div
v-if="latestRecharge.payment_instructions?.qr_code"
class="text-xs text-muted-foreground break-all"
>
二维码标识: {{ latestRecharge.payment_instructions.qr_code }}
</div>
</div>
</Card>
<Card class="p-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
申请退款
</h3>
<RefreshButton
:loading="loadingRefunds || loadingRefundEligibility"
@click="refreshRefundPanel"
/>
</div>
<div
v-if="!loadingRefundEligibility && refundableOrders.length === 0"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
>
当前没有开启用户自助退款的可退充值订单。
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label>退款金额 (USD)</Label>
<Input
v-model.number="refundForm.amount_usd"
type="number"
min="0.01"
step="0.01"
placeholder="5"
/>
</div>
<div class="space-y-1.5">
<Label>退款模式</Label>
<Select v-model="refundForm.refund_mode">
<SelectTrigger>
<SelectValue placeholder="选择退款模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="original_channel">
原路退回
</SelectItem>
<SelectItem value="offline_payout">
线下打款
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-1.5">
<Label>关联充值订单</Label>
<Select v-model="refundForm.payment_order_id">
<SelectTrigger>
<SelectValue placeholder="选择允许用户退款的订单" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="order in refundableOrders"
:key="order.id"
:value="order.id"
>
{{ order.order_no }} (可退 {{ formatCurrency(order.refundable_amount_usd) }})
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>退款原因(可选)</Label>
<Textarea
v-model="refundForm.reason"
placeholder="填写退款原因,便于审核"
rows="3"
/>
</div>
<div class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
仅开启“允许用户退款”的支付方式可由用户自助提交退款申请。
</div>
<Button
class="w-full"
variant="outline"
:disabled="submittingRefund || refundableOrders.length === 0"
@click="submitRefund"
>
{{ submittingRefund ? '提交中...' : '提交退款申请' }}
</Button>
</Card>
</div>
<Card class="overflow-hidden">
<div class="px-5 pt-5 pb-2">
<Tabs v-model="activeTab">
<TabsList class="tabs-button-list grid grid-cols-3 w-full max-w-xl">
<TabsTrigger value="transactions">
资金流水
</TabsTrigger>
<TabsTrigger value="orders">
充值订单
</TabsTrigger>
<TabsTrigger value="refunds">
退款记录
</TabsTrigger>
</TabsList>
<TabsContent
value="transactions"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ txTotal }} 条
</div>
<RefreshButton
:loading="loadingTransactions"
@click="loadTransactions"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>类型</TableHead>
<TableHead>变动</TableHead>
<TableHead>余额变化</TableHead>
<TableHead>说明</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="todayUsage">
<TableCell class="text-xs text-muted-foreground">
{{ todayUsage.date || '-' }}
</TableCell>
<TableCell>
<div class="space-y-1">
<div class="flex items-center gap-2">
<Badge
variant="outline"
class="font-mono border-amber-500/40 text-amber-700 dark:text-amber-300"
>
{{ dailyUsageCategoryLabel(true) }}
</Badge>
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span class="text-[11px] text-muted-foreground">
Live
</span>
</div>
<div class="text-[11px] text-muted-foreground">
{{ todayUsage.timezone || 'UTC' }}
</div>
</div>
</TableCell>
<TableCell class="text-rose-600 dark:text-rose-400">
-{{ todayUsage.total_cost.toFixed(4) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
按日汇总
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ todayUsage.total_requests }} 次请求 · {{ formatTokenCount(todayUsage.input_tokens) }} / {{ formatTokenCount(todayUsage.output_tokens) }} tokens
</TableCell>
</TableRow>
<template
v-for="item in flowItems"
:key="item.type === 'transaction' ? item.data.id : `daily-${item.data.id || item.data.date}`"
>
<TableRow v-if="item.type === 'transaction'">
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(item.data.created_at) }}
</TableCell>
<TableCell>
<div class="space-y-1">
<Badge
variant="outline"
class="font-mono"
>
{{ walletTransactionCategoryLabel(item.data.category) }}
</Badge>
<div class="text-[11px] text-muted-foreground">
{{ walletTransactionReasonLabel(item.data.reason_code) }}
</div>
</div>
</TableCell>
<TableCell
:class="item.data.amount >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'"
>
{{ item.data.amount >= 0 ? '+' : '' }}{{ item.data.amount.toFixed(4) }}
</TableCell>
<TableCell class="text-xs tabular-nums">
{{ item.data.balance_before.toFixed(4) }} → {{ item.data.balance_after.toFixed(4) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ item.data.description || '-' }}
</TableCell>
</TableRow>
<TableRow v-else>
<TableCell class="text-xs text-muted-foreground">
{{ item.data.date || '-' }}
</TableCell>
<TableCell>
<div class="space-y-1">
<Badge
variant="outline"
class="font-mono border-amber-500/40 text-amber-700 dark:text-amber-300"
>
{{ dailyUsageCategoryLabel(false) }}
</Badge>
<div class="text-[11px] text-muted-foreground">
{{ item.data.timezone || '-' }}
</div>
</div>
</TableCell>
<TableCell class="text-rose-600 dark:text-rose-400">
-{{ item.data.total_cost.toFixed(4) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
按日汇总
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ item.data.total_requests }} 次请求 · {{ formatTokenCount(item.data.input_tokens) }} / {{ formatTokenCount(item.data.output_tokens) }} tokens
</TableCell>
</TableRow>
</template>
<TableRow v-if="!loadingTransactions && flowItems.length === 0">
<TableCell
colspan="5"
class="py-10"
>
<EmptyState
title="暂无资金流水"
description="充值、退款或消费后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="txPage"
:total="txTotal"
:page-size="txPageSize"
@update:current="handleTxPageChange"
@update:page-size="handleTxPageSizeChange"
/>
</TabsContent>
<TabsContent
value="orders"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ orderTotal }} 条
</div>
<RefreshButton
:loading="loadingOrders"
@click="loadOrders"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>订单号</TableHead>
<TableHead>金额</TableHead>
<TableHead>支付方式</TableHead>
<TableHead>状态</TableHead>
<TableHead>可退金额</TableHead>
<TableHead>创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="order in rechargeOrders"
:key="order.id"
>
<TableCell class="font-mono text-xs">
{{ order.order_no }}
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(order.amount_usd) }}
</TableCell>
<TableCell>{{ paymentMethodLabel(order.payment_method) }}</TableCell>
<TableCell>
<Badge :variant="paymentStatusBadge(order.status)">
{{ paymentStatusLabel(order.status) }}
</Badge>
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(order.refundable_amount_usd) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(order.created_at) }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingOrders && rechargeOrders.length === 0">
<TableCell
colspan="6"
class="py-10"
>
<EmptyState
title="暂无充值订单"
description="发起充值后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="orderPage"
:total="orderTotal"
:page-size="orderPageSize"
@update:current="handleOrderPageChange"
@update:page-size="handleOrderPageSizeChange"
/>
</TabsContent>
<TabsContent
value="refunds"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ refundTotal }} 条
</div>
<RefreshButton
:loading="loadingRefunds"
@click="loadRefunds"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>退款单号</TableHead>
<TableHead>金额</TableHead>
<TableHead>模式</TableHead>
<TableHead>状态</TableHead>
<TableHead>原因</TableHead>
<TableHead>申请时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="refund in refunds"
:key="refund.id"
>
<TableCell class="font-mono text-xs">
{{ refund.refund_no }}
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(refund.amount_usd) }}
</TableCell>
<TableCell>{{ refundModeLabel(refund.refund_mode) }}</TableCell>
<TableCell>
<Badge :variant="refundStatusBadge(refund.status)">
{{ refundStatusLabel(refund.status) }}
</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground max-w-[220px] truncate">
{{ refund.reason || refund.failure_reason || '-' }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(refund.created_at) }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingRefunds && refunds.length === 0">
<TableCell
colspan="6"
class="py-10"
>
<EmptyState
title="暂无退款记录"
description="提交退款申请后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="refundPage"
:total="refundTotal"
:page-size="refundPageSize"
@update:current="handleRefundPageChange"
@update:page-size="handleRefundPageSizeChange"
/>
</TabsContent>
</Tabs>
</div>
</Card>
</template>
<StripePaymentDialog
v-model:open="stripeDialogOpen"
:instructions="stripePaymentInstructions"
title="钱包 Stripe 支付"
description="完成支付后,钱包余额会由 Stripe Webhook 自动入账。"
confirm-text="支付充值"
@success="handleStripePaymentSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { getI18nLocale } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import {
Badge,
Button,
Card,
Input,
Label,
Pagination,
RefreshButton,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { EmptyState, LoadingState, StripePaymentDialog } from '@/components/common'
import {
walletApi,
type DailyUsageRecord,
type FlowItem,
type PaymentOrder,
type RefundRequest,
type WalletBalanceResponse,
type WalletRedeemResponse,
type WalletRechargeOption,
} from '@/api/wallet'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import { safePaymentTargetUrl } from '@/utils/paymentUrl'
import {
getPaymentInstructionString,
getStripePaymentInstructions,
type PaymentInstructionMap,
} from '@/utils/paymentInstructions'
import {
dailyUsageCategoryLabel,
formatTokenCount,
formatWalletCurrency as formatCurrency,
paymentMethodLabel,
paymentStatusBadge,
paymentStatusLabel,
refundModeLabel,
refundStatusBadge,
refundStatusLabel,
walletStatusBadge,
walletStatusLabel,
walletTransactionCategoryLabel,
walletTransactionReasonLabel,
} from '@/utils/walletDisplay'
const { success, error: showError } = useToast()
const ENABLE_WALLET_ACTION_FORMS = true
const loadingInitial = ref(true)
const loadingTransactions = ref(false)
const loadingOrders = ref(false)
const loadingRefunds = ref(false)
const loadingRefundEligibility = ref(false)
const submittingRedeem = ref(false)
const submittingRecharge = ref(false)
const submittingRefund = ref(false)
const walletBalance = ref<WalletBalanceResponse | null>(null)
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
const latestRedeem = ref<WalletRedeemResponse | null>(null)
const rechargeOptions = ref<WalletRechargeOption[]>([])
const stripeDialogOpen = ref(false)
const stripePaymentInstructions = ref<PaymentInstructionMap | null>(null)
const flowItems = ref<FlowItem[]>([])
const todayUsage = ref<DailyUsageRecord | null>(null)
const txTotal = ref(0)
const txPage = ref(1)
const txPageSize = ref(20)
const rechargeOrders = ref<PaymentOrder[]>([])
const orderTotal = ref(0)
const orderPage = ref(1)
const orderPageSize = ref(20)
const refundEligiblePaymentMethods = ref<Set<string>>(new Set())
const refunds = ref<RefundRequest[]>([])
const refundTotal = ref(0)
const refundPage = ref(1)
const refundPageSize = ref(20)
const activeTab = ref('transactions')
const loadedTabs = new Set<string>()
const tabLoadPromises = new Map<string, Promise<void>>()
let refundEligibilityLoaded = false
let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
const rechargeForm = reactive({
amount_usd: 10,
payment_option_key: '',
idempotency_key: '',
})
let rechargeIdempotencyFingerprint = ''
const refundForm = reactive({
amount_usd: 0,
payment_order_id: '',
refund_mode: 'offline_payout',
reason: '',
})
const redeemForm = reactive({
code: '',
})
const refundableOrders = computed(() =>
rechargeOrders.value.filter(order =>
(order.refundable_amount_usd || 0) > 0
&& refundEligiblePaymentMethods.value.has(refundPaymentMethod(order))
)
)
const rechargeOptionsWithKey = computed(() =>
rechargeOptions.value.map((option, index) => ({
...option,
key: [
option.payment_provider || option.provider || option.payment_method,
option.payment_method,
option.payment_channel || '',
index,
].join(':'),
}))
)
const selectedRechargeOption = computed(() => {
if (rechargeOptionsWithKey.value.length === 0) return null
return rechargeOptionsWithKey.value.find(option => option.key === rechargeForm.payment_option_key)
|| rechargeOptionsWithKey.value[0]
})
function roundPayAmount(value: number): number {
return Math.round(value * 100) / 100
}
const rechargePaymentBreakdown = computed(() => {
const rate = Number(selectedRechargeOption.value?.usd_exchange_rate || 0)
const amount = Number(rechargeForm.amount_usd || 0)
if (!Number.isFinite(rate) || rate <= 0 || !Number.isFinite(amount) || amount <= 0) return null
const rawFeeRate = Number(selectedRechargeOption.value?.fee_rate || 0)
const feeRate = Number.isFinite(rawFeeRate) && rawFeeRate > 0 ? rawFeeRate : 0
const basePayAmount = roundPayAmount(amount * rate)
const feeAmount = roundPayAmount(basePayAmount * feeRate / 100)
return {
basePayAmount,
feeAmount,
feeRate,
totalPayAmount: roundPayAmount(basePayAmount + feeAmount),
}
})
const rechargePayCurrency = computed(() => selectedRechargeOption.value?.pay_currency || 'CNY')
const estimatedRechargePayAmount = computed(() => {
if (!rechargePaymentBreakdown.value) return '-'
return rechargePaymentBreakdown.value.totalPayAmount.toFixed(2)
})
const estimatedRechargeFeeAmount = computed(() =>
rechargePaymentBreakdown.value?.feeAmount || 0
)
const estimatedRechargeFeeRate = computed(() =>
rechargePaymentBreakdown.value?.feeRate || 0
)
const latestRechargePaymentUrl = computed(() => {
const paymentUrl = getPaymentInstructionString(
latestRecharge.value?.payment_instructions,
'payment_url',
)
return paymentUrl ? safePaymentTargetUrl(paymentUrl) : null
})
const latestRechargeStripeInstructions = computed(() =>
getStripePaymentInstructions(latestRecharge.value?.payment_instructions)
)
const dailyQuota = computed(() => walletBalance.value?.daily_quota ?? null)
const hasActiveDailyQuota = computed(() => Boolean(dailyQuota.value?.has_active))
const walletOnlyBalance = computed(() => {
const explicitBalance = walletBalance.value?.wallet_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return explicitBalance
}
return Number(walletBalance.value?.balance ?? 0)
})
const packageBalance = computed(() => {
const quotaRemaining = dailyQuota.value?.remaining_usd
if (hasActiveDailyQuota.value && typeof quotaRemaining === 'number' && Number.isFinite(quotaRemaining)) {
return Math.max(0, quotaRemaining)
}
const explicitBalance = walletBalance.value?.package_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return Math.max(0, explicitBalance)
}
return 0
})
const totalAvailableBalance = computed(() => {
const explicitBalance = walletBalance.value?.total_available_balance
if (typeof explicitBalance === 'number' && Number.isFinite(explicitBalance)) {
return explicitBalance
}
return walletOnlyBalance.value + packageBalance.value
})
const dailyQuotaTotal = computed(() => {
const value = dailyQuota.value?.total_usd
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
})
const dailyQuotaUsed = computed(() => {
const value = dailyQuota.value?.used_usd
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0
})
const dailyQuotaRemainingPercent = computed(() => {
if (!hasActiveDailyQuota.value || dailyQuotaTotal.value <= 0) return 0
return Math.min(100, Math.max(0, (packageBalance.value / dailyQuotaTotal.value) * 100))
})
onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
try {
await Promise.all([
loadBalance(),
loadTransactions(),
loadRechargeOptions(),
])
syncTodayCostPolling()
} finally {
loadingInitial.value = false
}
})
onBeforeUnmount(() => {
stopTodayCostPolling()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
watch(activeTab, (tab) => {
syncTodayCostPolling()
void loadActiveTab(tab)
})
watch(refundableOrders, () => {
syncRefundOrderSelection()
})
async function refreshWallet() {
await Promise.all([loadBalance(), loadOrders(), loadTransactions()])
}
async function loadBalance() {
walletBalance.value = await walletApi.getBalance()
}
async function loadRechargeOptions() {
try {
const response = await walletApi.listRechargeOptions()
rechargeOptions.value = response.items
if (!rechargeForm.payment_option_key && rechargeOptionsWithKey.value.length > 0) {
const preferred = rechargeOptionsWithKey.value.find(option => option.payment_provider === 'epay')
|| rechargeOptionsWithKey.value[0]
rechargeForm.payment_option_key = preferred.key
}
} catch (error) {
log.error('加载充值方式失败:', error)
showError(parseApiError(error, '加载充值方式失败'))
}
}
async function loadTransactions() {
loadingTransactions.value = true
try {
const offset = (txPage.value - 1) * txPageSize.value
const resp = await walletApi.getFlow({ limit: txPageSize.value, offset })
flowItems.value = resp.items
txTotal.value = resp.total
todayUsage.value = resp.today_entry
loadedTabs.add('transactions')
} catch (error) {
log.error('加载钱包流水失败:', error)
showError(parseApiError(error, '加载钱包流水失败'))
} finally {
loadingTransactions.value = false
}
}
async function loadTodayCost() {
try {
todayUsage.value = await walletApi.getTodayCost()
} catch (error) {
log.error('加载今日消费失败:', error)
}
}
function syncTodayCostPolling() {
if (activeTab.value === 'transactions' && !document.hidden) {
startTodayCostPolling()
} else {
stopTodayCostPolling()
}
}
function startTodayCostPolling() {
if (todayCostPollTimer) return
todayCostPollTimer = setInterval(() => {
void loadTodayCost()
}, 20_000)
}
function stopTodayCostPolling() {
if (!todayCostPollTimer) return
clearInterval(todayCostPollTimer)
todayCostPollTimer = null
}
function handleVisibilityChange() {
syncTodayCostPolling()
}
async function loadOrders() {
loadingOrders.value = true
try {
const offset = (orderPage.value - 1) * orderPageSize.value
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
rechargeOrders.value = resp.items
orderTotal.value = resp.total
loadedTabs.add('orders')
syncRefundOrderSelection()
} catch (error) {
log.error('加载充值订单失败:', error)
showError(parseApiError(error, '加载充值订单失败'))
} finally {
loadingOrders.value = false
}
}
async function loadRefundEligibility() {
loadingRefundEligibility.value = true
try {
const resp = await walletApi.listRefundEligibleProviders()
refundEligiblePaymentMethods.value = new Set(
(resp.payment_methods || [])
.map(item => item.trim().toLowerCase())
.filter(Boolean)
)
refundEligibilityLoaded = true
syncRefundOrderSelection()
} catch (error) {
refundEligiblePaymentMethods.value = new Set()
log.error('加载退款资格失败:', error)
} finally {
loadingRefundEligibility.value = false
}
}
async function loadRefunds() {
loadingRefunds.value = true
try {
const offset = (refundPage.value - 1) * refundPageSize.value
const resp = await walletApi.listRefunds({ limit: refundPageSize.value, offset })
refunds.value = resp.items
refundTotal.value = resp.total
loadedTabs.add('refunds')
} catch (error) {
log.error('加载退款记录失败:', error)
showError(parseApiError(error, '加载退款记录失败'))
} finally {
loadingRefunds.value = false
}
}
function loadActiveTab(tab: string): Promise<void> {
const tabIsLoaded = tab === 'refunds'
? loadedTabs.has('refunds') && loadedTabs.has('orders') && refundEligibilityLoaded
: loadedTabs.has(tab)
if (tabIsLoaded) return Promise.resolve()
const existing = tabLoadPromises.get(tab)
if (existing) return existing
const request = (async () => {
if (tab === 'orders') {
await loadOrders()
} else if (tab === 'refunds') {
const requests: Promise<void>[] = []
if (!loadedTabs.has('refunds')) requests.push(loadRefunds())
if (!refundEligibilityLoaded) requests.push(loadRefundEligibility())
if (!loadedTabs.has('orders')) requests.push(loadOrders())
await Promise.all(requests)
}
})().finally(() => {
if (tabLoadPromises.get(tab) === request) tabLoadPromises.delete(tab)
})
tabLoadPromises.set(tab, request)
return request
}
async function refreshRefundPanel() {
await Promise.all([loadRefunds(), loadRefundEligibility(), loadOrders()])
}
async function submitRedeem() {
if (!redeemForm.code.trim()) {
showError('请输入兑换码')
return
}
submittingRedeem.value = true
try {
latestRedeem.value = await walletApi.redeemCode({
code: redeemForm.code.trim(),
})
redeemForm.code = ''
success('兑换成功')
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
activeTab.value = 'orders'
} catch (error) {
log.error('兑换码充值失败:', error)
showError(parseApiError(error, '兑换码充值失败'))
} finally {
submittingRedeem.value = false
}
}
async function submitRecharge() {
if (!rechargeForm.amount_usd || rechargeForm.amount_usd <= 0) {
showError('请输入有效的充值金额')
return
}
const option = selectedRechargeOption.value
if (!option) {
showError('请选择支付方式')
return
}
if (option.min_recharge_usd && rechargeForm.amount_usd < option.min_recharge_usd) {
showError(`充值金额不能低于 ${formatCurrency(option.min_recharge_usd)}`)
return
}
submittingRecharge.value = true
try {
// Keep a failed request retryable only while its business parameters stay
// identical. Changing the amount or channel must get a fresh key.
const requestFingerprint = JSON.stringify([
Number(rechargeForm.amount_usd),
option.payment_method || '',
option.payment_provider || '',
option.payment_channel || '',
])
if (
rechargeForm.idempotency_key
&& rechargeIdempotencyFingerprint
&& rechargeIdempotencyFingerprint !== requestFingerprint
) {
rechargeForm.idempotency_key = ''
}
if (!rechargeForm.idempotency_key) {
rechargeForm.idempotency_key = typeof globalThis.crypto?.randomUUID === 'function'
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
}
rechargeIdempotencyFingerprint = requestFingerprint
latestRecharge.value = await walletApi.createRechargeOrder({
amount_usd: rechargeForm.amount_usd,
payment_method: option.payment_method,
payment_provider: option.payment_provider,
payment_channel: option.payment_channel,
idempotency_key: rechargeForm.idempotency_key,
})
success('充值订单创建成功')
await Promise.all([loadOrders(), loadBalance()])
activeTab.value = 'orders'
submitPaymentInstructions(latestRecharge.value.payment_instructions)
rechargeForm.idempotency_key = ''
rechargeIdempotencyFingerprint = ''
} catch (error) {
log.error('创建充值订单失败:', error)
showError(parseApiError(error, '创建充值订单失败'))
} finally {
submittingRecharge.value = false
}
}
function submitPaymentInstructions(instructions: Record<string, unknown> | null | undefined) {
if (!instructions) return
const stripeInstructions = getStripePaymentInstructions(instructions)
if (stripeInstructions) {
stripePaymentInstructions.value = instructions
stripeDialogOpen.value = true
return
}
const paymentUrl = getPaymentInstructionString(instructions, 'payment_url')
if (!paymentUrl) return
const safePaymentUrl = safePaymentTargetUrl(paymentUrl)
if (!safePaymentUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const paymentParams = instructions.payment_params
if (paymentParams && typeof paymentParams === 'object' && !Array.isArray(paymentParams)) {
submitPaymentForm(safePaymentUrl, paymentParams as Record<string, unknown>)
return
}
const opened = window.open(safePaymentUrl, '_blank', 'noopener,noreferrer')
if (!opened) {
window.location.href = safePaymentUrl
}
}
function submitPaymentForm(url: string, params: Record<string, unknown>) {
const safeUrl = safePaymentTargetUrl(url)
if (!safeUrl) {
showError('支付网关返回了不安全的支付地址')
return
}
const form = document.createElement('form')
form.action = safeUrl
form.method = 'POST'
if (!isSafariBrowser()) {
form.target = '_blank'
}
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) return
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = String(value)
form.appendChild(input)
})
document.body.appendChild(form)
form.submit()
document.body.removeChild(form)
}
function isSafariBrowser(): boolean {
return navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome')
}
async function handleStripePaymentSuccess() {
success('支付已完成,正在刷新钱包余额')
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
activeTab.value = 'orders'
}
async function submitRefund() {
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
showError('请输入有效的退款金额')
return
}
const selectedOrder = refundableOrders.value.find(order => order.id === refundForm.payment_order_id)
if (!selectedOrder) {
showError('请选择允许用户退款的充值订单')
return
}
if (refundForm.amount_usd > (selectedOrder.refundable_amount_usd || 0)) {
showError(`退款金额超过该订单可退金额(当前可退 ${formatCurrency(selectedOrder.refundable_amount_usd || 0)})`)
return
}
const refundableBalance =
walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null
if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) {
showError(`退款金额超过可退款余额(当前可退 ${formatCurrency(refundableBalance)})`)
return
}
submittingRefund.value = true
try {
await walletApi.createRefund({
amount_usd: refundForm.amount_usd,
payment_order_id: selectedOrder.id,
reason: refundForm.reason || undefined,
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
})
success('退款申请已提交')
refundForm.amount_usd = 0
refundForm.reason = ''
await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadRefundEligibility(), loadTransactions(), loadTodayCost()])
activeTab.value = 'refunds'
} catch (error) {
log.error('提交退款申请失败:', error)
showError(parseApiError(error, '提交退款申请失败'))
} finally {
submittingRefund.value = false
}
}
function refundPaymentMethod(order: PaymentOrder): string {
return String(order.payment_provider || order.payment_method || '').trim().toLowerCase()
}
function syncRefundOrderSelection() {
if (refundableOrders.value.some(order => order.id === refundForm.payment_order_id)) return
refundForm.payment_order_id = refundableOrders.value[0]?.id || ''
}
function buildRefundIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replaceAll('-', '')
}
return `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`
}
function handleTxPageChange(page: number) {
txPage.value = page
void loadTransactions()
}
function handleTxPageSizeChange(size: number) {
txPageSize.value = size
txPage.value = 1
void loadTransactions()
}
function handleOrderPageChange(page: number) {
orderPage.value = page
void loadOrders()
}
function handleOrderPageSizeChange(size: number) {
orderPageSize.value = size
orderPage.value = 1
void loadOrders()
}
function handleRefundPageChange(page: number) {
refundPage.value = page
void loadRefunds()
}
function handleRefundPageSizeChange(size: number) {
refundPageSize.value = size
refundPage.value = 1
void loadRefunds()
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-'
return new Date(value).toLocaleString(getI18nLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>