feat(payments): 增加兑换码与支付适配框架 (#299)

* feat(payments): 增加兑换码与支付适配框架

* fix(ci): 对齐 Rust 1.95 lint 与格式要求

* fix(payments): harden redeem code wallet credits

---------

Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
Entropy.Xu
2026-04-17 10:07:52 +08:00
committed by GitHub
parent 54d77598ae
commit 6964729cb7
36 changed files with 5129 additions and 274 deletions

View File

@@ -39,6 +39,75 @@ export interface AdminPaymentCreditRequest {
gateway_response?: Record<string, unknown>
}
export interface RedeemCodeBatch {
id: string
name: string
amount_usd: number
currency: string
balance_bucket: string
total_count: number
redeemed_count: number
active_count: number
status: string
description?: string | null
created_by?: string | null
expires_at?: string | null
created_at: string | null
updated_at: string | null
}
export interface RedeemCodeRecord {
id: string
batch_id: string
batch_name?: string | null
code_prefix: string
code_suffix: string
masked_code: string
status: string
redeemed_by_user_id?: string | null
redeemed_by_user_name?: string | null
redeemed_wallet_id?: string | null
redeemed_payment_order_id?: string | null
redeemed_order_no?: string | null
redeemed_at?: string | null
disabled_by?: string | null
expires_at?: string | null
created_at: string | null
updated_at: string | null
}
export interface CreateRedeemCodeBatchRequest {
name: string
amount_usd: number
total_count: number
expires_at?: string
description?: string
}
export interface CreateRedeemCodeBatchResponse {
batch: RedeemCodeBatch
codes: Array<{
id: string
code: string
masked_code: string
}>
}
export interface RedeemCodeBatchListResponse {
items: RedeemCodeBatch[]
total: number
limit: number
offset: number
}
export interface RedeemCodeListResponse {
batch: RedeemCodeBatch
items: RedeemCodeRecord[]
total: number
limit: number
offset: number
}
export const adminPaymentsApi = {
async listOrders(params?: {
status?: string
@@ -90,4 +159,72 @@ export const adminPaymentsApi = {
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
return response.data
},
async listRedeemCodeBatches(params?: {
status?: string
limit?: number
offset?: number
}): Promise<RedeemCodeBatchListResponse> {
const response = await apiClient.get<RedeemCodeBatchListResponse>(
'/api/admin/payments/redeem-codes/batches',
{ params }
)
return response.data
},
async createRedeemCodeBatch(
payload: CreateRedeemCodeBatchRequest
): Promise<CreateRedeemCodeBatchResponse> {
const response = await apiClient.post<CreateRedeemCodeBatchResponse>(
'/api/admin/payments/redeem-codes/batches',
payload
)
return response.data
},
async getRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
const response = await apiClient.get<{ batch: RedeemCodeBatch }>(
`/api/admin/payments/redeem-codes/batches/${batchId}`
)
return response.data
},
async listRedeemCodes(
batchId: string,
params?: {
status?: string
limit?: number
offset?: number
}
): Promise<RedeemCodeListResponse> {
const response = await apiClient.get<RedeemCodeListResponse>(
`/api/admin/payments/redeem-codes/batches/${batchId}/codes`,
{ params }
)
return response.data
},
async disableRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
const response = await apiClient.post<{ batch: RedeemCodeBatch }>(
`/api/admin/payments/redeem-codes/batches/${batchId}/disable`,
{}
)
return response.data
},
async deleteRedeemCodeBatch(batchId: string): Promise<{ batch: RedeemCodeBatch }> {
const response = await apiClient.post<{ batch: RedeemCodeBatch }>(
`/api/admin/payments/redeem-codes/batches/${batchId}/delete`,
{}
)
return response.data
},
async disableRedeemCode(codeId: string): Promise<{ code: RedeemCodeRecord }> {
const response = await apiClient.post<{ code: RedeemCodeRecord }>(
`/api/admin/payments/redeem-codes/codes/${codeId}/disable`,
{}
)
return response.data
},
}

View File

@@ -150,6 +150,17 @@ export interface WalletRefundCreateRequest {
idempotency_key?: string
}
export interface WalletRedeemRequest {
code: string
}
export interface WalletRedeemResponse {
order: PaymentOrder
wallet: WalletSummary
amount_usd: number
batch_name: string
}
export const walletApi = {
async getBalance(): Promise<WalletBalanceResponse> {
const response = await apiClient.get<WalletBalanceResponse>('/api/wallet/balance')
@@ -213,4 +224,9 @@ export const walletApi = {
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
return response.data
},
async redeemCode(payload: WalletRedeemRequest): Promise<WalletRedeemResponse> {
const response = await apiClient.post<WalletRedeemResponse>('/api/wallet/redeem', payload)
return response.data
},
}

View File

@@ -14,7 +14,7 @@
<div class="px-5 py-5">
<Tabs v-model="activeTab">
<TabsList class="tabs-button-list grid w-full max-w-[760px] grid-cols-4">
<TabsList class="tabs-button-list grid w-full max-w-[960px] grid-cols-5">
<TabsTrigger value="ledger">
资金流水
</TabsTrigger>
@@ -27,6 +27,9 @@
<TabsTrigger value="callbacks">
回调日志
</TabsTrigger>
<TabsTrigger value="redeem_codes">
兑换码
</TabsTrigger>
</TabsList>
<TabsContent
@@ -617,6 +620,334 @@
@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>
@@ -1007,7 +1338,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
Badge,
@@ -1023,6 +1354,7 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Table,
TableBody,
TableCell,
@@ -1033,6 +1365,7 @@ import {
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { EmptyState } from '@/components/common'
import { X } from 'lucide-vue-next'
@@ -1041,7 +1374,12 @@ import {
type AdminGlobalRefund,
type AdminLedgerTransaction,
} from '@/api/admin-wallets'
import { adminPaymentsApi, type PaymentCallbackRecord } from '@/api/admin-payments'
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'
@@ -1062,7 +1400,7 @@ import {
walletTransactionReasonLabel,
} from '@/utils/walletDisplay'
type WalletManagementTab = 'ledger' | 'refunds' | 'orders' | 'callbacks'
type WalletManagementTab = 'ledger' | 'refunds' | 'orders' | 'callbacks' | 'redeem_codes'
type LedgerCategory = 'recharge' | 'gift' | 'adjust' | 'refund'
type LedgerReasonOption = {
value: string
@@ -1092,8 +1430,11 @@ 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)
@@ -1129,6 +1470,43 @@ 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)
@@ -1188,6 +1566,22 @@ watch(callbackMethodFilter, () => {
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) => {
@@ -1206,11 +1600,12 @@ onMounted(async () => {
loadRefunds(),
loadOrders(),
loadCallbacks(),
loadRedeemCodeBatches(),
])
})
function isValidTab(tab: unknown): tab is WalletManagementTab {
return tab === 'ledger' || tab === 'refunds' || tab === 'orders' || tab === 'callbacks'
return tab === 'ledger' || tab === 'refunds' || tab === 'orders' || tab === 'callbacks' || tab === 'redeem_codes'
}
async function loadWalletMetaMap() {
@@ -1316,6 +1711,202 @@ async function loadCallbacks() {
}
}
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(`确认删除批次「${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 || '未知钱包'
}
@@ -1568,6 +2159,28 @@ function handleCallbackPageSizeChange(size: number) {
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' ? '用户钱包' : '独立密钥'
}
@@ -1587,6 +2200,20 @@ function formatDateTime(value: string | null | undefined) {
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>

View File

@@ -56,6 +56,52 @@
</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="() => Promise.all([loadBalance(), loadOrders(), loadTransactions()])"
/>
</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"
@@ -572,6 +618,7 @@ import {
type PaymentOrder,
type RefundRequest,
type WalletBalanceResponse,
type WalletRedeemResponse,
} from '@/api/wallet'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
@@ -601,11 +648,13 @@ const loadingInitial = ref(true)
const loadingTransactions = ref(false)
const loadingOrders = ref(false)
const loadingRefunds = 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 flowItems = ref<FlowItem[]>([])
const todayUsage = ref<DailyUsageRecord | null>(null)
@@ -638,6 +687,10 @@ const refundForm = reactive({
reason: '',
})
const redeemForm = reactive({
code: '',
})
const refundableOrders = computed(() =>
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
)
@@ -750,6 +803,29 @@ async function loadRefunds() {
}
}
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('请输入有效的充值金额')