Files
Aether/frontend/src/views/admin/WalletsManagement.vue
T

2266 lines
82 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">
<Card class="overflow-hidden">
<div class="px-5 py-4 border-b border-border/60 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 class="text-base font-semibold">
钱包管理
</h3>
<p class="text-xs text-muted-foreground mt-1">
统一管理资金流水、退款审批、充值订单与支付回调
</p>
</div>
</div>
<div class="px-5 py-5">
<Tabs v-model="activeTab">
<TabsList class="tabs-button-list grid w-full max-w-[960px] grid-cols-5">
<TabsTrigger value="ledger">
资金流水
</TabsTrigger>
<TabsTrigger value="orders">
充值订单
</TabsTrigger>
<TabsTrigger value="refunds">
退款审批
</TabsTrigger>
<TabsTrigger value="callbacks">
回调日志
</TabsTrigger>
<TabsTrigger value="redeem_codes">
兑换码
</TabsTrigger>
</TabsList>
<TabsContent
value="ledger"
class="mt-5 space-y-4"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="ledgerCategoryFilter">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="一级分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部分类
</SelectItem>
<SelectItem value="recharge">
充值
</SelectItem>
<SelectItem value="gift">
赠款
</SelectItem>
<SelectItem value="adjust">
调账
</SelectItem>
<SelectItem value="refund">
退款
</SelectItem>
</SelectContent>
</Select>
<Select v-model="ledgerReasonFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="二级分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部二级
</SelectItem>
<SelectItem
v-for="option in ledgerReasonOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<Select v-model="ledgerOwnerFilter">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="归属类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部归属
</SelectItem>
<SelectItem value="user">
用户钱包
</SelectItem>
<SelectItem value="api_key">
独立密钥钱包
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-muted-foreground">
共 {{ ledgerTotal }} 条
</div>
<RefreshButton
:loading="loadingLedger"
@click="loadLedger"
/>
</div>
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>归属</TableHead>
<TableHead>类型</TableHead>
<TableHead>金额</TableHead>
<TableHead>余额变化</TableHead>
<TableHead>说明</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="tx in ledgerItems"
:key="tx.id"
class="hover:bg-muted/20"
>
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
{{ formatDateTime(tx.created_at) }}
</TableCell>
<TableCell class="min-w-[180px]">
<div class="font-medium text-sm">
{{ ownerDisplayName(tx.owner_name, tx.owner_type) }}
</div>
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-2">
<span>{{ ownerTypeLabel(tx.owner_type) }}</span>
<Badge
v-if="tx.wallet_status"
variant="outline"
class="text-[10px]"
>
{{ walletStatusLabel(tx.wallet_status) }}
</Badge>
</div>
</TableCell>
<TableCell>
<div class="space-y-1">
<Badge
variant="outline"
class="font-mono"
>
{{ walletTransactionCategoryLabel(tx.category) }}
</Badge>
<div class="text-[11px] text-muted-foreground">
{{ walletTransactionReasonLabel(tx.reason_code) }}
</div>
</div>
</TableCell>
<TableCell :class="tx.amount >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'">
{{ tx.amount >= 0 ? '+' : '' }}{{ tx.amount.toFixed(4) }}
</TableCell>
<TableCell class="text-xs tabular-nums whitespace-nowrap">
<div>{{ tx.balance_before.toFixed(4) }} → {{ tx.balance_after.toFixed(4) }}</div>
<div
v-if="tx.recharge_balance_before !== null && tx.recharge_balance_before !== undefined && tx.gift_balance_before !== null && tx.gift_balance_before !== undefined"
class="text-[11px] text-muted-foreground mt-0.5"
>
充 {{ Number(tx.recharge_balance_before).toFixed(4) }}→{{ Number(tx.recharge_balance_after ?? 0).toFixed(4) }}
· 赠 {{ Number(tx.gift_balance_before).toFixed(4) }}→{{ Number(tx.gift_balance_after ?? 0).toFixed(4) }}
</div>
</TableCell>
<TableCell class="text-xs text-muted-foreground max-w-[280px] truncate">
{{ tx.description || '-' }}
</TableCell>
<TableCell class="text-right">
<Button
size="sm"
variant="outline"
@click="openLedgerDrawer(tx)"
>
详情
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!loadingLedger && ledgerItems.length === 0">
<TableCell
colspan="7"
class="py-12"
>
<EmptyState
title="暂无资金流水"
description="当前筛选条件下没有资金动作记录"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="ledgerPage"
:total="ledgerTotal"
:page-size="ledgerPageSize"
@update:current="handleLedgerPageChange"
@update:page-size="handleLedgerPageSizeChange"
/>
</TabsContent>
<TabsContent
value="refunds"
class="mt-5 space-y-4"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="refundStatusFilter">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="退款状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="pending_approval">
待审批
</SelectItem>
<SelectItem value="approved">
已审批
</SelectItem>
<SelectItem value="processing">
处理中
</SelectItem>
<SelectItem value="succeeded">
已完成
</SelectItem>
<SelectItem value="failed">
已失败
</SelectItem>
</SelectContent>
</Select>
<Select v-model="refundOwnerFilter">
<SelectTrigger class="w-[170px]">
<SelectValue placeholder="归属类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部归属
</SelectItem>
<SelectItem value="user">
用户钱包
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-3">
<div class="text-sm text-muted-foreground">
共 {{ refundTotal }} 条
</div>
<RefreshButton
:loading="loadingRefunds"
@click="loadRefunds"
/>
</div>
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>归属</TableHead>
<TableHead>退款单号</TableHead>
<TableHead>金额</TableHead>
<TableHead>模式</TableHead>
<TableHead>状态</TableHead>
<TableHead>原因</TableHead>
<TableHead>申请时间</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="refund in refundItems"
:key="refund.id"
class="hover:bg-muted/20"
>
<TableCell class="min-w-[180px]">
<div class="font-medium text-sm">
{{ ownerDisplayName(refund.owner_name, refund.owner_type) }}
</div>
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-2">
<span>{{ ownerTypeLabel(refund.owner_type) }}</span>
<Badge
v-if="refund.wallet_status"
variant="outline"
class="text-[10px]"
>
{{ walletStatusLabel(refund.wallet_status) }}
</Badge>
</div>
</TableCell>
<TableCell class="font-mono text-xs whitespace-nowrap">
{{ refund.refund_no }}
</TableCell>
<TableCell class="tabular-nums whitespace-nowrap">
{{ 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-[240px] truncate">
{{ refund.reason || refund.failure_reason || '-' }}
</TableCell>
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
{{ formatDateTime(refund.created_at) }}
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
@click="openRefundDrawer(refund)"
>
审批
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!loadingRefunds && refundItems.length === 0">
<TableCell
colspan="8"
class="py-12"
>
<EmptyState
title="暂无退款申请"
description="当前筛选条件下没有退款单"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="refundPage"
:total="refundTotal"
:page-size="refundPageSize"
@update:current="handleRefundPageChange"
@update:page-size="handleRefundPageSizeChange"
/>
</TabsContent>
<TabsContent
value="orders"
class="mt-5 space-y-4"
>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="orderStatusFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="订单状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="pending">
待支付
</SelectItem>
<SelectItem value="paid">
已支付
</SelectItem>
<SelectItem value="credited">
已到账
</SelectItem>
<SelectItem value="failed">
支付失败
</SelectItem>
<SelectItem value="expired">
已过期
</SelectItem>
</SelectContent>
</Select>
<Select v-model="orderMethodFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="支付方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部方式
</SelectItem>
<SelectItem value="alipay">
支付宝
</SelectItem>
<SelectItem value="wechat">
微信支付
</SelectItem>
<SelectItem value="admin_manual">
人工充值
</SelectItem>
<SelectItem value="card_code">
充值卡
</SelectItem>
<SelectItem value="gift_code">
礼品卡
</SelectItem>
<SelectItem value="card_recharge">
卡密充值
</SelectItem>
</SelectContent>
</Select>
<RefreshButton
:loading="loadingOrders"
@click="loadOrders"
/>
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>订单号</TableHead>
<TableHead>钱包名称</TableHead>
<TableHead>金额</TableHead>
<TableHead>支付方式</TableHead>
<TableHead>状态</TableHead>
<TableHead>创建时间</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="order in orders"
:key="order.id"
>
<TableCell class="font-mono text-xs whitespace-nowrap">
{{ order.order_no }}
</TableCell>
<TableCell class="min-w-[180px]">
<div class="text-sm font-medium">
{{ orderWalletName(order.wallet_id) }}
</div>
<div class="text-xs text-muted-foreground mt-1">
{{ orderWalletTypeLabel(order.wallet_id) }}
</div>
</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="text-xs text-muted-foreground whitespace-nowrap">
{{ formatDateTime(order.created_at) }}
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
v-if="canCreditOrder(order.status)"
size="sm"
@click="openCreditDialog(order)"
>
到账
</Button>
<Button
v-if="canExpireOrder(order.status)"
size="sm"
variant="outline"
:disabled="submittingOrderAction"
@click="expireOrder(order.id)"
>
过期
</Button>
<Button
v-if="canFailOrder(order.status)"
size="sm"
variant="destructive"
:disabled="submittingOrderAction"
@click="failOrder(order.id)"
>
失败
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!loadingOrders && orders.length === 0">
<TableCell
colspan="7"
class="py-10"
>
<EmptyState
title="暂无支付订单"
description="当前筛选条件下没有数据"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="orderPage"
:total="orderTotal"
:page-size="orderPageSize"
@update:current="handleOrderPageChange"
@update:page-size="handleOrderPageSizeChange"
/>
</TabsContent>
<TabsContent
value="callbacks"
class="mt-5 space-y-4"
>
<div class="flex items-center gap-2">
<Select v-model="callbackMethodFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="支付方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部方式
</SelectItem>
<SelectItem value="alipay">
支付宝
</SelectItem>
<SelectItem value="wechat">
微信支付
</SelectItem>
</SelectContent>
</Select>
<RefreshButton
:loading="loadingCallbacks"
@click="loadCallbacks"
/>
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<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="callback in callbacks"
:key="callback.id"
>
<TableCell class="font-mono text-xs">
{{ callback.callback_key }}
</TableCell>
<TableCell class="font-mono text-xs whitespace-nowrap">
{{ callback.order_no || '-' }}
</TableCell>
<TableCell>{{ paymentMethodLabel(callback.payment_method) }}</TableCell>
<TableCell>
<Badge :variant="callback.signature_valid ? 'success' : 'destructive'">
{{ callback.signature_valid ? '通过' : '失败' }}
</Badge>
</TableCell>
<TableCell>
<Badge :variant="callbackStatusBadge(callback.status)">
{{ callbackStatusLabel(callback.status) }}
</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground whitespace-nowrap">
{{ formatDateTime(callback.created_at) }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingCallbacks && callbacks.length === 0">
<TableCell
colspan="6"
class="py-10"
>
<EmptyState
title="暂无回调日志"
description="当前筛选条件下没有数据"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="callbackPage"
:total="callbackTotal"
:page-size="callbackPageSize"
@update:current="handleCallbackPageChange"
@update:page-size="handleCallbackPageSizeChange"
/>
</TabsContent>
<TabsContent
value="redeem_codes"
class="mt-5 space-y-5"
>
<div class="rounded-2xl border border-border/60 bg-background p-4 space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h4 class="text-sm font-semibold">
批量生成兑换码
</h4>
<p class="text-xs text-muted-foreground mt-1">
生成后本会话可切换显示明文;页面刷新后仅保留脱敏码。
</p>
</div>
<RefreshButton
:loading="loadingRedeemBatches || loadingRedeemCodes"
@click="loadRedeemCodeBatches"
/>
</div>
<div class="grid gap-3 lg:grid-cols-4">
<div class="space-y-1.5">
<Label>批次名称</Label>
<Input v-model="redeemBatchForm.name" />
</div>
<div class="space-y-1.5">
<Label>面额 (USD)</Label>
<Input
v-model.number="redeemBatchForm.amount_usd"
type="number"
min="0.01"
step="0.01"
/>
</div>
<div class="space-y-1.5">
<Label>生成数量</Label>
<Input
v-model.number="redeemBatchForm.total_count"
type="number"
min="1"
step="1"
/>
</div>
<div class="space-y-1.5">
<Label>过期时间(可选)</Label>
<Input
v-model="redeemBatchForm.expires_at"
type="datetime-local"
/>
</div>
</div>
<div class="space-y-1.5">
<Label>备注(可选)</Label>
<Textarea
v-model="redeemBatchForm.description"
rows="3"
placeholder="例如:五一活动 / 线下渠道 / KOC 发放"
/>
</div>
<div class="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
:disabled="!canExportLatestGeneratedRedeemCodes"
@click="exportLatestGeneratedRedeemCodes"
>
导出最近生成
</Button>
<Button
:disabled="submittingRedeemBatch"
@click="submitRedeemCodeBatch"
>
{{ submittingRedeemBatch ? '生成中...' : '生成兑换码' }}
</Button>
</div>
<div
v-if="latestGeneratedRedeemBatch"
class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"
>
最近生成批次:
<span class="font-medium text-foreground">{{ latestGeneratedRedeemBatch.name }}</span>
· {{ latestGeneratedRedeemCodes.length }} 个兑换码
</div>
</div>
<div class="grid gap-5 xl:grid-cols-[1.1fr_1fr]">
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<Select v-model="redeemBatchStatusFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="批次状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="active">
可用
</SelectItem>
<SelectItem value="disabled">
已停用
</SelectItem>
</SelectContent>
</Select>
<div class="text-sm text-muted-foreground">
共 {{ redeemBatchTotal }} 个批次
</div>
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>批次</TableHead>
<TableHead>面额</TableHead>
<TableHead>数量</TableHead>
<TableHead>状态</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="batch in redeemBatches"
:key="batch.id"
class="hover:bg-muted/20"
:class="batch.id === selectedRedeemBatchId ? 'bg-muted/30 ring-1 ring-border/60' : ''"
>
<TableCell class="min-w-[220px]">
<div class="text-sm font-medium">
{{ batch.name }}
</div>
<div class="text-xs text-muted-foreground mt-1">
过期: {{ formatDateTime(batch.expires_at) }}
</div>
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(batch.amount_usd) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ batch.redeemed_count }} / {{ batch.total_count }} 已使用
</TableCell>
<TableCell>
<Badge :variant="batch.status === 'active' ? 'success' : 'secondary'">
{{ batch.status === 'active' ? '可用' : '已停用' }}
</Badge>
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button
size="sm"
:variant="batch.id === selectedRedeemBatchId ? 'default' : 'outline'"
@click="selectRedeemBatch(batch)"
>
{{ batch.id === selectedRedeemBatchId ? '当前查看' : '查看码' }}
</Button>
<Button
v-if="batch.status === 'active'"
size="sm"
variant="destructive"
@click="disableRedeemBatch(batch.id)"
>
停用批次
</Button>
<Button
v-if="batch.status === 'disabled'"
size="sm"
variant="destructive"
:disabled="batch.redeemed_count > 0"
@click="deleteRedeemBatch(batch)"
>
删除批次
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="!loadingRedeemBatches && redeemBatches.length === 0">
<TableCell
colspan="5"
class="py-10"
>
<EmptyState
title="暂无兑换码批次"
description="创建批次后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="redeemBatchPage"
:total="redeemBatchTotal"
:page-size="redeemBatchPageSize"
@update:current="handleRedeemBatchPageChange"
@update:page-size="handleRedeemBatchPageSizeChange"
/>
</div>
<div
ref="redeemCodesPanelRef"
class="space-y-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h4 class="text-sm font-semibold">
{{ currentRedeemBatch?.name || '兑换码列表' }}
</h4>
<p class="text-xs text-muted-foreground mt-1">
{{ currentRedeemBatch ? `面额 ${formatCurrency(currentRedeemBatch.amount_usd)} · 剩余 ${currentRedeemBatch.active_count}` : '先从左侧选择一个批次' }}
</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">显示明文</span>
<Switch
:model-value="showPlainRedeemCodes"
:disabled="!canRevealPlainRedeemCodes"
@update:model-value="showPlainRedeemCodes = Boolean($event)"
/>
</div>
<Select v-model="redeemCodeStatusFilter">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="码状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部状态
</SelectItem>
<SelectItem value="active">
可用
</SelectItem>
<SelectItem value="disabled">
已停用
</SelectItem>
<SelectItem value="redeemed">
已兑换
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="text-xs text-muted-foreground">
{{
canRevealPlainRedeemCodes
? '当前批次属于本次生成,已支持明文显示开关。'
: '仅当前会话内最近生成的一批兑换码支持明文显示;其余批次仅显示脱敏码。'
}}
</div>
<div class="rounded-2xl border border-border/60 overflow-hidden bg-background">
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>兑换码</TableHead>
<TableHead>状态</TableHead>
<TableHead>兑换用户</TableHead>
<TableHead>关联订单</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="code in redeemCodes"
:key="code.id"
>
<TableCell class="font-mono text-xs">
{{ displayRedeemCode(code) }}
</TableCell>
<TableCell>
<Badge :variant="redeemCodeStatusBadge(code.status)">
{{ redeemCodeStatusLabel(code.status) }}
</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ code.redeemed_by_user_name || code.redeemed_by_user_id || '-' }}
</TableCell>
<TableCell class="font-mono text-xs">
{{ code.redeemed_order_no || code.redeemed_payment_order_id || '-' }}
</TableCell>
<TableCell class="text-right">
<Button
v-if="code.status === 'active'"
size="sm"
variant="outline"
@click="disableRedeemCode(code.id)"
>
停用
</Button>
</TableCell>
</TableRow>
<TableRow v-if="!loadingRedeemCodes && redeemCodes.length === 0">
<TableCell
colspan="5"
class="py-10"
>
<EmptyState
title="暂无兑换码"
description="选择左侧批次后会显示兑换码明细"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<Pagination
:current="redeemCodePage"
:total="redeemCodeTotal"
:page-size="redeemCodePageSize"
@update:current="handleRedeemCodePageChange"
@update:page-size="handleRedeemCodePageSizeChange"
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</Card>
<Teleport to="body">
<Transition name="drawer">
<div
v-if="showLedgerDrawer && currentLedger"
class="fixed inset-0 z-[80] flex justify-end"
>
<div
class="absolute inset-0 bg-black/35 backdrop-blur-sm"
@click="closeLedgerDrawer"
/>
<div class="drawer-panel relative h-full w-full sm:w-[760px] lg:w-[860px] sm:max-w-[95vw] border-l border-border bg-background shadow-2xl overflow-y-auto">
<div class="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur px-4 py-3 sm:px-6 sm:py-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
流水详情
</h3>
<p class="text-xs text-muted-foreground">
资金动作审计信息
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0"
title="关闭"
@click="closeLedgerDrawer"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
<div class="p-4 sm:p-6 space-y-5">
<div class="rounded-2xl border border-border/60 bg-muted/30 p-4 space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Badge variant="outline">
{{ walletTransactionCategoryLabel(currentLedger.category) }}
</Badge>
<Badge variant="secondary">
{{ walletTransactionReasonLabel(currentLedger.reason_code) }}
</Badge>
</div>
<span
class="text-sm font-semibold tabular-nums"
:class="currentLedger.amount >= 0 ? 'text-emerald-600' : 'text-rose-600'"
>
{{ currentLedger.amount >= 0 ? '+' : '' }}{{ currentLedger.amount.toFixed(4) }}
</span>
</div>
<div class="text-xs text-muted-foreground">
{{ formatDateTime(currentLedger.created_at) }}
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
归属
</div>
<div class="mt-1 text-sm font-medium">
{{ ownerDisplayName(currentLedger.owner_name, currentLedger.owner_type) }}
</div>
<div class="mt-1 text-xs text-muted-foreground flex items-center gap-2">
<span>{{ ownerTypeLabel(currentLedger.owner_type) }}</span>
<Badge
v-if="currentLedger.wallet_status"
variant="outline"
class="text-[10px]"
>
{{ walletStatusLabel(currentLedger.wallet_status) }}
</Badge>
</div>
</div>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
余额变化
</div>
<div class="mt-1 text-sm font-medium tabular-nums">
{{ currentLedger.balance_before.toFixed(4) }} → {{ currentLedger.balance_after.toFixed(4) }}
</div>
<div
v-if="currentLedger.recharge_balance_before !== null && currentLedger.recharge_balance_before !== undefined && currentLedger.gift_balance_before !== null && currentLedger.gift_balance_before !== undefined"
class="mt-1 text-xs text-muted-foreground tabular-nums"
>
充 {{ Number(currentLedger.recharge_balance_before).toFixed(4) }}→{{ Number(currentLedger.recharge_balance_after ?? 0).toFixed(4) }}
· 赠 {{ Number(currentLedger.gift_balance_before).toFixed(4) }}→{{ Number(currentLedger.gift_balance_after ?? 0).toFixed(4) }}
</div>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
关联类型
</div>
<div class="mt-1 text-sm font-medium break-all">
{{ walletLinkTypeLabel(currentLedger.link_type) }}
</div>
</div>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
交易ID
</div>
<div class="mt-1 text-sm font-mono break-all">
{{ currentLedger.id }}
</div>
</div>
</div>
<div
v-if="currentLedger.link_type === 'payment_order'"
class="grid gap-3 sm:grid-cols-2"
>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
支付方式
</div>
<div class="mt-1 text-sm font-medium">
<span v-if="loadingLedgerOrderNo">加载中...</span>
<span v-else>{{ ledgerPaymentMethod ? paymentMethodLabel(ledgerPaymentMethod) : '-' }}</span>
</div>
</div>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
充值订单号
</div>
<div class="mt-1 text-sm font-mono break-all">
<span v-if="loadingLedgerOrderNo">加载中...</span>
<span v-else>{{ ledgerPaymentOrderNo || '-' }}</span>
</div>
</div>
</div>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
操作用户
</div>
<div class="mt-1 text-sm font-medium">
{{ currentLedger.operator_name || (currentLedger.operator_id ? '已删除用户' : '系统自动') }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
ID: {{ currentLedger.operator_id || '-' }}
</div>
<div
v-if="currentLedger.operator_email"
class="mt-1 text-xs text-muted-foreground"
>
邮箱: {{ currentLedger.operator_email }}
</div>
</div>
<div class="rounded-xl border border-border/60 p-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
说明
</div>
<div class="mt-1 text-sm text-foreground whitespace-pre-wrap break-words">
{{ currentLedger.description || '-' }}
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<Transition name="drawer">
<div
v-if="showRefundDrawer && currentRefund"
class="fixed inset-0 z-[80] flex justify-end"
>
<div
class="absolute inset-0 bg-black/35 backdrop-blur-sm"
@click="closeRefundDrawer"
/>
<div class="drawer-panel relative h-full w-full sm:w-[760px] lg:w-[860px] sm:max-w-[95vw] border-l border-border bg-background shadow-2xl overflow-y-auto">
<div class="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur px-4 py-3 sm:px-6 sm:py-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
退款审批
</h3>
<p class="text-xs text-muted-foreground">
退款单: {{ currentRefund.refund_no }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="h-9 w-9 shrink-0"
title="关闭"
@click="closeRefundDrawer"
>
<X class="h-4 w-4" />
</Button>
</div>
</div>
<div class="p-4 sm:p-6 space-y-5">
<div class="rounded-2xl border border-border/60 bg-muted/30 p-4">
<div class="grid gap-3 sm:grid-cols-2">
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
归属
</div>
<div class="mt-1 text-sm font-medium">
{{ ownerDisplayName(currentRefund.owner_name, currentRefund.owner_type) }}
</div>
<div class="mt-1 text-xs text-muted-foreground">
{{ ownerTypeLabel(currentRefund.owner_type) }}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
金额
</div>
<div class="mt-1 text-sm font-semibold tabular-nums">
{{ formatCurrency(currentRefund.amount_usd) }}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
退款模式
</div>
<div class="mt-1 text-sm">
{{ refundModeLabel(currentRefund.refund_mode) }}
</div>
</div>
<div>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
状态
</div>
<div class="mt-1">
<Badge :variant="refundStatusBadge(currentRefund.status)">
{{ refundStatusLabel(currentRefund.status) }}
</Badge>
</div>
</div>
<div class="sm:col-span-2">
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
申请原因
</div>
<div class="mt-1 text-sm text-foreground whitespace-pre-wrap break-words">
{{ currentRefund.reason || '-' }}
</div>
</div>
<div
v-if="currentRefund.failure_reason"
class="sm:col-span-2"
>
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
失败原因
</div>
<div class="mt-1 text-sm text-rose-600 whitespace-pre-wrap break-words">
{{ currentRefund.failure_reason }}
</div>
</div>
</div>
</div>
<div
v-if="canFailRefund(currentRefund.status)"
class="rounded-xl border border-border/60 p-4 space-y-2"
>
<Label>驳回原因</Label>
<Input
v-model="failRefundForm.reason"
placeholder="请填写驳回原因"
/>
</div>
<div
v-if="canCompleteRefund(currentRefund.status)"
class="rounded-xl border border-border/60 p-4 space-y-3"
>
<div class="space-y-1.5">
<Label>网关退款号(可选)</Label>
<Input v-model="completeRefundForm.gateway_refund_id" />
</div>
<div class="space-y-1.5">
<Label>打款凭证 / 参考号(可选)</Label>
<Input v-model="completeRefundForm.payout_reference" />
</div>
</div>
</div>
<div class="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur px-4 py-3 sm:px-6 sm:py-4">
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
@click="closeRefundDrawer"
>
关闭
</Button>
<Button
v-if="canProcessRefund(currentRefund.status)"
variant="outline"
:disabled="submittingRefundAction"
@click="processRefund(currentRefund)"
>
{{ submittingRefundAction ? '处理中...' : '处理退款' }}
</Button>
<Button
v-if="canCompleteRefund(currentRefund.status)"
:disabled="submittingRefundAction"
@click="submitCompleteRefund"
>
{{ submittingRefundAction ? '提交中...' : '确认完成' }}
</Button>
<Button
v-if="canFailRefund(currentRefund.status)"
variant="destructive"
:disabled="submittingRefundAction"
@click="submitFailRefund"
>
{{ submittingRefundAction ? '提交中...' : '驳回退款' }}
</Button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<Dialog v-model="showCreditDialog">
<template #header>
<div class="px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold">
人工到账
</h3>
<p class="text-xs text-muted-foreground mt-1">
订单: {{ currentOrder?.order_no || '-' }}
</p>
</div>
</template>
<div class="space-y-4">
<div class="space-y-1.5">
<Label>网关订单号(可选)</Label>
<Input v-model="creditForm.gateway_order_id" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label>实付金额(可选)</Label>
<Input
v-model.number="creditForm.pay_amount"
type="number"
min="0.01"
step="0.01"
/>
</div>
<div class="space-y-1.5">
<Label>币种(可选)</Label>
<Input v-model="creditForm.pay_currency" />
</div>
<div class="space-y-1.5">
<Label>汇率(可选)</Label>
<Input
v-model.number="creditForm.exchange_rate"
type="number"
min="0.000001"
step="0.000001"
/>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="showCreditDialog = false"
>
取消
</Button>
<Button
:disabled="submittingOrderAction"
@click="submitCreditOrder"
>
{{ submittingOrderAction ? '提交中...' : '确认到账' }}
</Button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
Badge,
Button,
Card,
Dialog,
Input,
Label,
Pagination,
RefreshButton,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { EmptyState } from '@/components/common'
import { X } from 'lucide-vue-next'
import {
adminWalletApi,
type AdminGlobalRefund,
type AdminLedgerTransaction,
} from '@/api/admin-wallets'
import {
adminPaymentsApi,
type PaymentCallbackRecord,
type RedeemCodeBatch,
type RedeemCodeRecord,
} from '@/api/admin-payments'
import type { PaymentOrder } from '@/api/wallet'
import { parseApiError } from '@/utils/errorParser'
import { useToast } from '@/composables/useToast'
import { useI18n } from '@/i18n'
import { log } from '@/utils/logger'
import {
callbackStatusBadge,
callbackStatusLabel,
formatWalletCurrency as formatCurrency,
paymentMethodLabel,
paymentStatusBadge,
paymentStatusLabel,
refundModeLabel,
refundStatusBadge,
refundStatusLabel,
walletLinkTypeLabel,
walletStatusLabel,
walletTransactionCategoryLabel,
walletTransactionReasonLabel,
} from '@/utils/walletDisplay'
type WalletManagementTab = 'ledger' | 'refunds' | 'orders' | 'callbacks' | 'redeem_codes'
type LedgerCategory = 'recharge' | 'gift' | 'adjust' | 'refund'
type LedgerReasonOption = {
value: string
label: string
category: LedgerCategory
}
const LEDGER_REASON_OPTIONS: LedgerReasonOption[] = [
{ value: 'topup_admin_manual', label: '人工充值', category: 'recharge' },
{ value: 'topup_gateway', label: '支付充值', category: 'recharge' },
{ value: 'topup_card_code', label: '卡密充值', category: 'recharge' },
{ value: 'gift_initial', label: '初始赠款', category: 'gift' },
{ value: 'gift_campaign', label: '活动赠款', category: 'gift' },
{ value: 'gift_expire_reclaim', label: '赠款回收', category: 'gift' },
{ value: 'adjust_admin', label: '人工调账', category: 'adjust' },
{ value: 'adjust_system', label: '系统调账', category: 'adjust' },
{ value: 'refund_out', label: '退款扣减', category: 'refund' },
{ value: 'refund_revert', label: '退款回补', category: 'refund' },
]
const { success, error: showError } = useToast()
const { legacyT } = useI18n()
const route = useRoute()
const activeTab = ref<WalletManagementTab>('ledger')
const activeTabLoadPromises = new Map<WalletManagementTab, Promise<void>>()
const loadingLedger = ref(false)
const loadingRefunds = ref(false)
const loadingOrders = ref(false)
const loadingCallbacks = ref(false)
const loadingRedeemBatches = ref(false)
const loadingRedeemCodes = ref(false)
const submittingRefundAction = ref(false)
const submittingOrderAction = ref(false)
const submittingRedeemBatch = ref(false)
const ledgerItems = ref<AdminLedgerTransaction[]>([])
const ledgerTotal = ref(0)
const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerCategoryFilter = ref('all')
const ledgerReasonFilter = ref('all')
const ledgerOwnerFilter = ref('all')
const ledgerReasonOptions = computed(() => {
if (ledgerCategoryFilter.value === 'all') {
return LEDGER_REASON_OPTIONS
}
return LEDGER_REASON_OPTIONS.filter((option) => option.category === ledgerCategoryFilter.value)
})
const refundItems = ref<AdminGlobalRefund[]>([])
const refundTotal = ref(0)
const refundPage = ref(1)
const refundPageSize = ref(20)
const refundStatusFilter = ref('all')
const refundOwnerFilter = ref('all')
const orders = ref<PaymentOrder[]>([])
const orderTotal = ref(0)
const orderPage = ref(1)
const orderPageSize = ref(20)
const orderStatusFilter = ref('all')
const orderMethodFilter = ref('all')
const callbacks = ref<PaymentCallbackRecord[]>([])
const callbackTotal = ref(0)
const callbackPage = ref(1)
const callbackPageSize = ref(20)
const callbackMethodFilter = ref('all')
const redeemBatches = ref<RedeemCodeBatch[]>([])
const redeemBatchTotal = ref(0)
const redeemBatchPage = ref(1)
const redeemBatchPageSize = ref(20)
const redeemBatchStatusFilter = ref('all')
const redeemCodes = ref<RedeemCodeRecord[]>([])
const redeemCodeTotal = ref(0)
const redeemCodePage = ref(1)
const redeemCodePageSize = ref(20)
const redeemCodeStatusFilter = ref('all')
const selectedRedeemBatchId = ref<string | null>(null)
const currentRedeemBatch = ref<RedeemCodeBatch | null>(null)
const latestGeneratedRedeemBatch = ref<RedeemCodeBatch | null>(null)
const latestGeneratedRedeemCodes = ref<Array<{ id: string; code: string; masked_code: string }>>([])
const showPlainRedeemCodes = ref(false)
const redeemCodesPanelRef = ref<HTMLElement | null>(null)
const redeemBatchForm = reactive({
name: '',
amount_usd: 10,
total_count: 20,
expires_at: '',
description: '',
})
const canRevealPlainRedeemCodes = computed(
() =>
!!currentRedeemBatch.value &&
currentRedeemBatch.value.id === latestGeneratedRedeemBatch.value?.id &&
latestGeneratedRedeemCodes.value.length > 0
)
const canExportLatestGeneratedRedeemCodes = computed(
() => !!latestGeneratedRedeemBatch.value && latestGeneratedRedeemCodes.value.length > 0
)
const walletMetaMap = ref<Record<string, { ownerName: string; ownerType: 'user' | 'api_key' }>>({})
const showLedgerDrawer = ref(false)
const showRefundDrawer = ref(false)
const currentLedger = ref<AdminLedgerTransaction | null>(null)
const currentRefund = ref<AdminGlobalRefund | null>(null)
const loadingLedgerOrderNo = ref(false)
const ledgerPaymentOrderNo = ref<string | null>(null)
const ledgerPaymentMethod = ref<string | null>(null)
const showCreditDialog = ref(false)
const currentOrder = ref<PaymentOrder | null>(null)
const failRefundForm = reactive({
reason: '',
})
const completeRefundForm = reactive({
gateway_refund_id: '',
payout_reference: '',
})
const creditForm = reactive({
gateway_order_id: '',
pay_amount: undefined as number | undefined,
pay_currency: '',
exchange_rate: undefined as number | undefined,
})
watch([ledgerCategoryFilter, ledgerReasonFilter, ledgerOwnerFilter], () => {
ledgerPage.value = 1
void loadLedger()
})
watch(ledgerCategoryFilter, () => {
if (ledgerReasonFilter.value === 'all') {
return
}
const valid = ledgerReasonOptions.value.some((option) => option.value === ledgerReasonFilter.value)
if (!valid) {
ledgerReasonFilter.value = 'all'
}
})
watch([refundStatusFilter, refundOwnerFilter], () => {
refundPage.value = 1
void loadRefunds()
})
watch([orderStatusFilter, orderMethodFilter], () => {
orderPage.value = 1
void loadOrders()
})
watch(callbackMethodFilter, () => {
callbackPage.value = 1
void loadCallbacks()
})
watch(redeemBatchStatusFilter, () => {
redeemBatchPage.value = 1
void loadRedeemCodeBatches()
})
watch(redeemCodeStatusFilter, () => {
redeemCodePage.value = 1
void loadRedeemCodes()
})
watch(canRevealPlainRedeemCodes, (enabled) => {
if (!enabled) {
showPlainRedeemCodes.value = false
}
})
watch(
() => route.query.tab,
(tab) => {
const tabValue = Array.isArray(tab) ? tab[0] : tab
if (isValidTab(tabValue)) {
activeTab.value = tabValue
}
},
{ immediate: true }
)
onMounted(async () => {
await loadActiveTab()
})
watch(activeTab, () => {
void loadActiveTab()
})
function isValidTab(tab: unknown): tab is WalletManagementTab {
return tab === 'ledger' || tab === 'refunds' || tab === 'orders' || tab === 'callbacks' || tab === 'redeem_codes'
}
function loadActiveTab(): Promise<void> {
const tab = activeTab.value
const existing = activeTabLoadPromises.get(tab)
if (existing) return existing
const request = (async () => {
switch (tab) {
case 'refunds':
return loadRefunds()
case 'orders':
await Promise.all([loadOrders(), loadWalletMetaMap()])
return
case 'callbacks':
return loadCallbacks()
case 'redeem_codes':
return loadRedeemCodeBatches()
default:
return loadLedger()
}
})().finally(() => {
if (activeTabLoadPromises.get(tab) === request) activeTabLoadPromises.delete(tab)
})
activeTabLoadPromises.set(tab, request)
return request
}
async function loadWalletMetaMap() {
try {
const wallets = await adminWalletApi.listAllWallets(undefined, { cacheTtlMs: 30_000 })
walletMetaMap.value = wallets.reduce<Record<string, { ownerName: string; ownerType: 'user' | 'api_key' }>>(
(acc, wallet) => {
const ownerName =
wallet.owner_name || (wallet.owner_type === 'user' ? '未命名用户' : '未命名密钥')
acc[wallet.id] = {
ownerName,
ownerType: wallet.owner_type,
}
return acc
},
{}
)
} catch (error) {
log.error('加载钱包名称映射失败:', error)
}
}
async function loadLedger() {
loadingLedger.value = true
try {
const offset = (ledgerPage.value - 1) * ledgerPageSize.value
const resp = await adminWalletApi.listLedger({
category: ledgerCategoryFilter.value !== 'all' ? ledgerCategoryFilter.value : undefined,
reason_code: ledgerReasonFilter.value !== 'all' ? ledgerReasonFilter.value : undefined,
owner_type: ledgerOwnerFilter.value !== 'all' ? ledgerOwnerFilter.value : undefined,
limit: ledgerPageSize.value,
offset,
})
ledgerItems.value = resp.items
ledgerTotal.value = resp.total
} catch (error) {
log.error('加载全局资金流水失败:', error)
showError(parseApiError(error, '加载全局资金流水失败'))
} finally {
loadingLedger.value = false
}
}
async function loadRefunds() {
loadingRefunds.value = true
try {
const offset = (refundPage.value - 1) * refundPageSize.value
const resp = await adminWalletApi.listGlobalRefunds({
status: refundStatusFilter.value !== 'all' ? refundStatusFilter.value : undefined,
owner_type: refundOwnerFilter.value === 'user' ? 'user' : undefined,
limit: refundPageSize.value,
offset,
})
refundItems.value = resp.items
refundTotal.value = resp.total
if (currentRefund.value) {
syncCurrentRefund(currentRefund.value.id)
}
} catch (error) {
log.error('加载全局退款列表失败:', error)
showError(parseApiError(error, '加载全局退款列表失败'))
} finally {
loadingRefunds.value = false
}
}
async function loadOrders() {
loadingOrders.value = true
try {
const offset = (orderPage.value - 1) * orderPageSize.value
const resp = await adminPaymentsApi.listOrders({
status: orderStatusFilter.value !== 'all' ? orderStatusFilter.value : undefined,
payment_method: orderMethodFilter.value !== 'all' ? orderMethodFilter.value : undefined,
limit: orderPageSize.value,
offset,
})
orders.value = resp.items
orderTotal.value = resp.total
} catch (error) {
log.error('加载支付订单失败:', error)
showError(parseApiError(error, '加载支付订单失败'))
} finally {
loadingOrders.value = false
}
}
async function loadCallbacks() {
loadingCallbacks.value = true
try {
const offset = (callbackPage.value - 1) * callbackPageSize.value
const resp = await adminPaymentsApi.listCallbacks({
payment_method: callbackMethodFilter.value !== 'all' ? callbackMethodFilter.value : undefined,
limit: callbackPageSize.value,
offset,
})
callbacks.value = resp.items
callbackTotal.value = resp.total
} catch (error) {
log.error('加载支付回调失败:', error)
showError(parseApiError(error, '加载支付回调失败'))
} finally {
loadingCallbacks.value = false
}
}
async function loadRedeemCodeBatches() {
loadingRedeemBatches.value = true
try {
const offset = (redeemBatchPage.value - 1) * redeemBatchPageSize.value
const resp = await adminPaymentsApi.listRedeemCodeBatches({
status: redeemBatchStatusFilter.value !== 'all' ? redeemBatchStatusFilter.value : undefined,
limit: redeemBatchPageSize.value,
offset,
})
redeemBatches.value = resp.items
redeemBatchTotal.value = resp.total
if (selectedRedeemBatchId.value) {
const latest = resp.items.find(item => item.id === selectedRedeemBatchId.value)
if (latest) {
currentRedeemBatch.value = latest
await loadRedeemCodes(latest.id)
} else {
selectedRedeemBatchId.value = null
currentRedeemBatch.value = null
redeemCodes.value = []
redeemCodeTotal.value = 0
}
}
} catch (error) {
log.error('加载兑换码批次失败:', error)
showError(parseApiError(error, '加载兑换码批次失败'))
} finally {
loadingRedeemBatches.value = false
}
}
async function loadRedeemCodes(batchId = selectedRedeemBatchId.value || undefined) {
if (!batchId) {
redeemCodes.value = []
redeemCodeTotal.value = 0
return
}
loadingRedeemCodes.value = true
try {
const offset = (redeemCodePage.value - 1) * redeemCodePageSize.value
const resp = await adminPaymentsApi.listRedeemCodes(batchId, {
status: redeemCodeStatusFilter.value !== 'all' ? redeemCodeStatusFilter.value : undefined,
limit: redeemCodePageSize.value,
offset,
})
currentRedeemBatch.value = resp.batch
selectedRedeemBatchId.value = resp.batch.id
redeemCodes.value = resp.items
redeemCodeTotal.value = resp.total
} catch (error) {
log.error('加载兑换码列表失败:', error)
showError(parseApiError(error, '加载兑换码列表失败'))
} finally {
loadingRedeemCodes.value = false
}
}
async function selectRedeemBatch(batch: RedeemCodeBatch) {
currentRedeemBatch.value = batch
selectedRedeemBatchId.value = batch.id
redeemCodePage.value = 1
await loadRedeemCodes(batch.id)
await nextTick()
redeemCodesPanelRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function exportRedeemCodesCsv(batch: RedeemCodeBatch, codes: Array<{ id: string; code: string; masked_code: string }>) {
const header = ['id', 'batch_name', 'code', 'masked_code']
const rows = codes.map(code => [code.id, batch.name, code.code, code.masked_code])
const csv = [header, ...rows]
.map(row => row.map(cell => `"${String(cell).replaceAll('"', '""')}"`).join(','))
.join('\n')
const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `redeem-codes-${batch.name}-${batch.id}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
async function submitRedeemCodeBatch() {
if (!redeemBatchForm.name.trim()) {
showError('请填写批次名称')
return
}
if (!redeemBatchForm.amount_usd || redeemBatchForm.amount_usd <= 0) {
showError('请填写有效面额')
return
}
if (!redeemBatchForm.total_count || redeemBatchForm.total_count <= 0) {
showError('请填写有效数量')
return
}
submittingRedeemBatch.value = true
try {
const payload = {
name: redeemBatchForm.name.trim(),
amount_usd: redeemBatchForm.amount_usd,
total_count: redeemBatchForm.total_count,
expires_at: redeemBatchForm.expires_at ? new Date(redeemBatchForm.expires_at).toISOString() : undefined,
description: redeemBatchForm.description.trim() || undefined,
}
const resp = await adminPaymentsApi.createRedeemCodeBatch(payload)
latestGeneratedRedeemBatch.value = resp.batch
latestGeneratedRedeemCodes.value = resp.codes
showPlainRedeemCodes.value = true
success('兑换码批次已创建')
redeemBatchForm.name = ''
redeemBatchForm.description = ''
redeemBatchForm.expires_at = ''
currentRedeemBatch.value = resp.batch
selectedRedeemBatchId.value = resp.batch.id
await loadRedeemCodeBatches()
await loadRedeemCodes(resp.batch.id)
} catch (error) {
log.error('创建兑换码批次失败:', error)
showError(parseApiError(error, '创建兑换码批次失败'))
} finally {
submittingRedeemBatch.value = false
}
}
function exportLatestGeneratedRedeemCodes() {
if (!latestGeneratedRedeemBatch.value || latestGeneratedRedeemCodes.value.length === 0) {
showError('当前没有可导出的新生成兑换码')
return
}
exportRedeemCodesCsv(latestGeneratedRedeemBatch.value, latestGeneratedRedeemCodes.value)
success('CSV 已导出')
}
function displayRedeemCode(code: RedeemCodeRecord) {
if (!showPlainRedeemCodes.value || !canRevealPlainRedeemCodes.value) {
return code.masked_code
}
return latestGeneratedRedeemCodes.value.find(item => item.id === code.id)?.code || code.masked_code
}
async function disableRedeemBatch(batchId: string) {
try {
await adminPaymentsApi.disableRedeemCodeBatch(batchId)
success('批次已停用')
await loadRedeemCodeBatches()
} catch (error) {
log.error('停用兑换码批次失败:', error)
showError(parseApiError(error, '停用兑换码批次失败'))
}
}
async function deleteRedeemBatch(batch: RedeemCodeBatch) {
if (batch.redeemed_count > 0) {
showError('已有兑换记录的批次不能删除')
return
}
if (!window.confirm(legacyT(`确认删除批次「${batch.name}」吗?删除后无法恢复。`))) {
return
}
try {
await adminPaymentsApi.deleteRedeemCodeBatch(batch.id)
success('批次已删除')
if (selectedRedeemBatchId.value === batch.id) {
selectedRedeemBatchId.value = null
currentRedeemBatch.value = null
redeemCodes.value = []
redeemCodeTotal.value = 0
showPlainRedeemCodes.value = false
}
if (latestGeneratedRedeemBatch.value?.id === batch.id) {
latestGeneratedRedeemBatch.value = null
latestGeneratedRedeemCodes.value = []
showPlainRedeemCodes.value = false
}
await loadRedeemCodeBatches()
} catch (error) {
log.error('删除兑换码批次失败:', error)
showError(parseApiError(error, '删除兑换码批次失败'))
}
}
async function disableRedeemCode(codeId: string) {
try {
await adminPaymentsApi.disableRedeemCode(codeId)
success('兑换码已停用')
await Promise.all([loadRedeemCodes(), loadRedeemCodeBatches()])
} catch (error) {
log.error('停用兑换码失败:', error)
showError(parseApiError(error, '停用兑换码失败'))
}
}
function orderWalletName(walletId: string) {
return walletMetaMap.value[walletId]?.ownerName || '未知钱包'
}
function orderWalletTypeLabel(walletId: string) {
const ownerType = walletMetaMap.value[walletId]?.ownerType
if (!ownerType) return '未知归属'
return ownerType === 'user' ? '用户钱包' : '独立密钥钱包'
}
function openLedgerDrawer(tx: AdminLedgerTransaction) {
currentLedger.value = tx
ledgerPaymentOrderNo.value = null
ledgerPaymentMethod.value = null
showLedgerDrawer.value = true
void resolveLedgerRechargeOrderNo(tx)
}
async function resolveLedgerRechargeOrderNo(tx: AdminLedgerTransaction) {
if (tx.link_type !== 'payment_order' || !tx.link_id) {
ledgerPaymentOrderNo.value = null
ledgerPaymentMethod.value = null
return
}
if (tx.link_id.startsWith('po_')) {
ledgerPaymentOrderNo.value = tx.link_id
ledgerPaymentMethod.value = null
return
}
loadingLedgerOrderNo.value = true
try {
const resp = await adminPaymentsApi.getOrder(tx.link_id)
ledgerPaymentOrderNo.value = resp.order.order_no || null
ledgerPaymentMethod.value = resp.order.payment_method || null
} catch (error) {
log.error('加载关联充值订单失败:', error)
ledgerPaymentOrderNo.value = null
ledgerPaymentMethod.value = null
} finally {
loadingLedgerOrderNo.value = false
}
}
function closeLedgerDrawer() {
showLedgerDrawer.value = false
}
function openRefundDrawer(refund: AdminGlobalRefund) {
currentRefund.value = refund
failRefundForm.reason = ''
completeRefundForm.gateway_refund_id = ''
completeRefundForm.payout_reference = ''
showRefundDrawer.value = true
}
function closeRefundDrawer() {
showRefundDrawer.value = false
}
function syncCurrentRefund(refundId: string) {
const latest = refundItems.value.find((item) => item.id === refundId)
if (latest) {
currentRefund.value = latest
}
}
async function processRefund(refund: AdminGlobalRefund) {
submittingRefundAction.value = true
try {
await adminWalletApi.processRefund(refund.wallet_id, refund.id)
success('退款已进入 processing')
await Promise.all([loadRefunds(), loadLedger()])
syncCurrentRefund(refund.id)
} catch (error) {
log.error('处理退款失败:', error)
showError(parseApiError(error, '处理退款失败'))
} finally {
submittingRefundAction.value = false
}
}
async function submitFailRefund() {
if (!currentRefund.value) return
if (!failRefundForm.reason.trim()) {
showError('请填写驳回原因')
return
}
submittingRefundAction.value = true
try {
await adminWalletApi.failRefund(currentRefund.value.wallet_id, currentRefund.value.id, {
reason: failRefundForm.reason.trim(),
})
success('退款已驳回')
await Promise.all([loadRefunds(), loadLedger()])
syncCurrentRefund(currentRefund.value.id)
} catch (error) {
log.error('驳回退款失败:', error)
showError(parseApiError(error, '驳回退款失败'))
} finally {
submittingRefundAction.value = false
}
}
async function submitCompleteRefund() {
if (!currentRefund.value) return
submittingRefundAction.value = true
try {
await adminWalletApi.completeRefund(currentRefund.value.wallet_id, currentRefund.value.id, {
gateway_refund_id: completeRefundForm.gateway_refund_id || undefined,
payout_reference: completeRefundForm.payout_reference || undefined,
})
success('退款已完成')
await Promise.all([loadRefunds(), loadLedger()])
syncCurrentRefund(currentRefund.value.id)
} catch (error) {
log.error('完成退款失败:', error)
showError(parseApiError(error, '完成退款失败'))
} finally {
submittingRefundAction.value = false
}
}
function openCreditDialog(order: PaymentOrder) {
currentOrder.value = order
creditForm.gateway_order_id = order.gateway_order_id || ''
creditForm.pay_amount = order.pay_amount || undefined
creditForm.pay_currency = order.pay_currency || ''
creditForm.exchange_rate = order.exchange_rate || undefined
showCreditDialog.value = true
}
async function submitCreditOrder() {
if (!currentOrder.value) return
submittingOrderAction.value = true
try {
await adminPaymentsApi.creditOrder(currentOrder.value.id, {
gateway_order_id: creditForm.gateway_order_id || undefined,
pay_amount: creditForm.pay_amount,
pay_currency: creditForm.pay_currency || undefined,
exchange_rate: creditForm.exchange_rate,
})
success('订单已手动到账')
showCreditDialog.value = false
await Promise.all([loadOrders(), loadLedger(), loadWalletMetaMap()])
} catch (error) {
log.error('手动到账失败:', error)
showError(parseApiError(error, '手动到账失败'))
} finally {
submittingOrderAction.value = false
}
}
async function expireOrder(orderId: string) {
submittingOrderAction.value = true
try {
await adminPaymentsApi.expireOrder(orderId)
success('订单已标记过期')
await loadOrders()
} catch (error) {
log.error('标记过期失败:', error)
showError(parseApiError(error, '标记过期失败'))
} finally {
submittingOrderAction.value = false
}
}
async function failOrder(orderId: string) {
submittingOrderAction.value = true
try {
await adminPaymentsApi.failOrder(orderId)
success('订单已标记失败')
await loadOrders()
} catch (error) {
log.error('标记失败失败:', error)
showError(parseApiError(error, '标记失败失败'))
} finally {
submittingOrderAction.value = false
}
}
function canProcessRefund(status: string) {
return status === 'pending_approval' || status === 'approved'
}
function canFailRefund(status: string) {
return status === 'processing' || status === 'pending_approval' || status === 'approved'
}
function canCompleteRefund(status: string) {
return status === 'processing'
}
function canCreditOrder(status: string) {
return status === 'pending' || status === 'paid'
}
function canExpireOrder(status: string) {
return status === 'pending'
}
function canFailOrder(status: string) {
return status !== 'credited' && status !== 'refunded'
}
function handleLedgerPageChange(page: number) {
ledgerPage.value = page
void loadLedger()
}
function handleLedgerPageSizeChange(size: number) {
ledgerPageSize.value = size
ledgerPage.value = 1
void loadLedger()
}
function handleRefundPageChange(page: number) {
refundPage.value = page
void loadRefunds()
}
function handleRefundPageSizeChange(size: number) {
refundPageSize.value = size
refundPage.value = 1
void loadRefunds()
}
function handleOrderPageChange(page: number) {
orderPage.value = page
void loadOrders()
}
function handleOrderPageSizeChange(size: number) {
orderPageSize.value = size
orderPage.value = 1
void loadOrders()
}
function handleCallbackPageChange(page: number) {
callbackPage.value = page
void loadCallbacks()
}
function handleCallbackPageSizeChange(size: number) {
callbackPageSize.value = size
callbackPage.value = 1
void loadCallbacks()
}
function handleRedeemBatchPageChange(page: number) {
redeemBatchPage.value = page
void loadRedeemCodeBatches()
}
function handleRedeemBatchPageSizeChange(size: number) {
redeemBatchPageSize.value = size
redeemBatchPage.value = 1
void loadRedeemCodeBatches()
}
function handleRedeemCodePageChange(page: number) {
redeemCodePage.value = page
void loadRedeemCodes()
}
function handleRedeemCodePageSizeChange(size: number) {
redeemCodePageSize.value = size
redeemCodePage.value = 1
void loadRedeemCodes()
}
function ownerTypeLabel(ownerType: 'user' | 'api_key') {
return ownerType === 'user' ? '用户钱包' : '独立密钥'
}
function ownerDisplayName(name: string | null | undefined, ownerType: 'user' | 'api_key') {
if (name) return name
return ownerType === 'user' ? '未命名用户' : '未命名密钥'
}
function formatDateTime(value: string | null | undefined) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function redeemCodeStatusLabel(status: string) {
if (status === 'active') return '可用'
if (status === 'disabled') return '已停用'
if (status === 'redeemed') return '已兑换'
return status
}
function redeemCodeStatusBadge(status: string) {
if (status === 'active') return 'success'
if (status === 'disabled') return 'secondary'
if (status === 'redeemed') return 'outline'
return 'secondary'
}
</script>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.3s ease;
}
.drawer-enter-active .drawer-panel,
.drawer-leave-active .drawer-panel {
transition: transform 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-from .drawer-panel,
.drawer-leave-to .drawer-panel {
transform: translateX(100%);
}
</style>