mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('请输入有效的充值金额')
|
||||
|
||||
Reference in New Issue
Block a user