feat: add admin payment reusable components

- AdminOrderTable: reusable order table with search, filters, pagination
- AdminOrderDetail: order detail dialog with field grid and actions
- PaymentMethodChart: horizontal bar chart for payment distribution
- TopUsersLeaderboard: ranked top users table
This commit is contained in:
erio
2026-04-05 15:06:05 +08:00
parent 64c4495ae5
commit b1ef9f91f2
4 changed files with 511 additions and 0 deletions
@@ -0,0 +1,149 @@
<template>
<BaseDialog
:show="show"
:title="t('payment.admin.orderDetail')"
width="wide"
@close="emit('close')"
>
<div v-if="order" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p>
<p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ order.id }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p>
<span :class="['badge', statusBadgeClass(order.status)]">
{{ t('payment.status.' + order.status.toLowerCase(), order.status) }}
</span>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">${{ order.pay_amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + order.payment_type, order.payment_type) }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ (order.fee_rate * 100).toFixed(1) }}%</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderType') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.admin.' + order.order_type + 'Order', order.order_type) }}
</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.userId') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ order.user_id }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.created_at) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.expires_at) }}</p>
</div>
<div v-if="order.paid_at">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.paid_at) }}</p>
</div>
<div v-if="order.completed_at">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.completedAt') }}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(order.completed_at) }}</p>
</div>
</div>
<div
v-if="order.refund_amount"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20"
>
<h4 class="mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
{{ t('payment.admin.refundInfo') }}
</h4>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">${{ order.refund_amount.toFixed(2) }}</span>
</div>
<div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
<span class="ml-1 text-red-700 dark:text-red-300">{{ order.refund_reason }}</span>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-gray-200 pt-4 dark:border-dark-700">
<button
v-if="order.status === 'PENDING'"
@click="emit('cancel', order)"
class="btn btn-sm rounded-md bg-yellow-50 px-3 py-1.5 text-sm text-yellow-600 hover:bg-yellow-100 dark:bg-yellow-900/20 dark:text-yellow-400 dark:hover:bg-yellow-900/30"
>
{{ t('payment.orders.cancel') }}
</button>
<button
v-if="order.status === 'FAILED'"
@click="emit('retry', order)"
class="btn btn-sm btn-secondary"
>
{{ t('payment.admin.retry') }}
</button>
<button
v-if="canRefund(order)"
@click="emit('refund', order)"
class="btn btn-sm rounded-md bg-red-50 px-3 py-1.5 text-sm text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
>
{{ t('payment.admin.refund') }}
</button>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
const { t } = useI18n()
defineProps<{
show: boolean
order: PaymentOrder | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'cancel', order: PaymentOrder): void
(e: 'retry', order: PaymentOrder): void
(e: 'refund', order: PaymentOrder): void
}>()
function statusBadgeClass(status: string): string {
const m: Record<string, string> = {
PENDING: 'badge-warning', PAID: 'badge-info', RECHARGING: 'badge-info',
COMPLETED: 'badge-success', EXPIRED: 'badge-secondary', CANCELLED: 'badge-secondary',
FAILED: 'badge-danger', REFUND_REQUESTED: 'badge-warning', REFUNDING: 'badge-warning',
PARTIALLY_REFUNDED: 'badge-warning', REFUNDED: 'badge-info', REFUND_FAILED: 'badge-danger',
}
return m[status] || 'badge-secondary'
}
function canRefund(order: PaymentOrder): boolean {
return ['COMPLETED', 'PARTIALLY_REFUNDED'].includes(order.status)
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
@@ -0,0 +1,235 @@
<template>
<div class="space-y-4">
<div class="card p-4">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1 sm:max-w-64">
<input
v-model="searchQuery"
type="text"
:placeholder="t('payment.admin.searchOrders')"
class="input"
@input="handleSearch"
/>
</div>
<Select
v-model="filters.status"
:options="statusFilterOptions"
class="w-36"
@change="emitFiltersChanged"
/>
<Select
v-model="filters.payment_type"
:options="paymentTypeFilterOptions"
class="w-40"
@change="emitFiltersChanged"
/>
<Select
v-model="filters.order_type"
:options="orderTypeFilterOptions"
class="w-36"
@change="emitFiltersChanged"
/>
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
<button
@click="emit('refresh')"
:disabled="loading"
class="btn btn-secondary"
:title="t('common.refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
</div>
</div>
<DataTable :columns="columns" :data="orders" :loading="loading">
<template #cell-id="{ value }">
<span class="font-mono text-sm">#{{ value }}</span>
</template>
<template #cell-user_id="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">#{{ value }}</span>
</template>
<template #cell-amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">
({{ t('payment.orders.payAmount') }}: ${{ row.pay_amount.toFixed(2) }})
</span>
</div>
</template>
<template #cell-payment_type="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + value, value) }}
</span>
</template>
<template #cell-status="{ value }">
<span :class="['badge', statusBadgeClass(value)]">
{{ t('payment.status.' + value.toLowerCase(), value) }}
</span>
</template>
<template #cell-order_type="{ value }">
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.admin.' + value + 'Order', value) }}
</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
@click="emit('detail', row)"
class="btn-icon text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
:title="t('common.view')"
>
<Icon name="eye" size="sm" />
</button>
<button
v-if="row.status === 'PENDING'"
@click="emit('cancel', row)"
class="btn-icon text-yellow-500 hover:text-yellow-700"
:title="t('payment.orders.cancel')"
>
<Icon name="x" size="sm" />
</button>
<button
v-if="row.status === 'FAILED'"
@click="emit('retry', row)"
class="btn-icon text-blue-500 hover:text-blue-700"
:title="t('payment.admin.retry')"
>
<Icon name="refresh" size="sm" />
</button>
<button
v-if="canRefund(row)"
@click="emit('refund', row)"
class="btn-icon text-red-500 hover:text-red-700"
:title="t('payment.admin.refund')"
>
<Icon name="dollar" size="sm" />
</button>
</div>
</template>
</DataTable>
<Pagination
v-if="total > 0"
:page="page"
:total="total"
:page-size="pageSize"
@update:page="emit('update:page', $event)"
@update:pageSize="emit('update:pageSize', $event)"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { PaymentOrder } from '@/types/payment'
import type { Column } from '@/components/common/types'
import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
defineProps<{
orders: PaymentOrder[]
loading: boolean
page: number
pageSize: number
total: number
}>()
const emit = defineEmits<{
(e: 'detail', order: PaymentOrder): void
(e: 'cancel', order: PaymentOrder): void
(e: 'retry', order: PaymentOrder): void
(e: 'refund', order: PaymentOrder): void
(e: 'refresh'): void
(e: 'update:page', page: number): void
(e: 'update:pageSize', size: number): void
(e: 'filter', filters: { keyword?: string; status?: string; payment_type?: string; order_type?: string }): void
}>()
const searchQuery = ref('')
const filters = reactive({ status: '', payment_type: '', order_type: '' })
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function handleSearch() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => emitFiltersChanged(), 300)
}
function emitFiltersChanged() {
emit('filter', {
keyword: searchQuery.value || undefined,
status: filters.status || undefined,
payment_type: filters.payment_type || undefined,
order_type: filters.order_type || undefined,
})
}
const columns: Column[] = [
{ key: 'id', label: 'ID' },
{ key: 'user_id', label: 'User' },
{ key: 'amount', label: 'Amount' },
{ key: 'payment_type', label: 'Method' },
{ key: 'status', label: 'Status' },
{ key: 'order_type', label: 'Type' },
{ key: 'created_at', label: 'Created' },
{ key: 'actions', label: 'Actions' },
]
const statusFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allStatuses') },
{ value: 'PENDING', label: t('payment.status.pending') },
{ value: 'PAID', label: t('payment.status.paid') },
{ value: 'COMPLETED', label: t('payment.status.completed') },
{ value: 'EXPIRED', label: t('payment.status.expired') },
{ value: 'CANCELLED', label: t('payment.status.cancelled') },
{ value: 'FAILED', label: t('payment.status.failed') },
{ value: 'REFUNDED', label: t('payment.status.refunded') },
])
const paymentTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allPaymentTypes') },
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
])
const orderTypeFilterOptions = computed(() => [
{ value: '', label: t('payment.admin.allOrderTypes') },
{ value: 'balance', label: t('payment.admin.balanceOrder') },
{ value: 'subscription', label: t('payment.admin.subscriptionOrder') },
])
function statusBadgeClass(status: string): string {
const m: Record<string, string> = {
PENDING: 'badge-warning', PAID: 'badge-info', RECHARGING: 'badge-info',
COMPLETED: 'badge-success', EXPIRED: 'badge-secondary', CANCELLED: 'badge-secondary',
FAILED: 'badge-danger', REFUND_REQUESTED: 'badge-warning', REFUNDING: 'badge-warning',
PARTIALLY_REFUNDED: 'badge-warning', REFUNDED: 'badge-info', REFUND_FAILED: 'badge-danger',
}
return m[status] || 'badge-secondary'
}
function canRefund(order: PaymentOrder): boolean {
return ['COMPLETED', 'PARTIALLY_REFUNDED'].includes(order.status)
}
function formatDateTime(dateStr: string): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
@@ -0,0 +1,75 @@
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('payment.admin.paymentDistribution') }}
</h3>
<div
v-if="!methods?.length"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.noData') }}
</div>
<div v-else class="space-y-3">
<div v-for="method in methods" :key="method.type" class="space-y-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span :class="['inline-block h-3 w-3 rounded-full', colorMap[method.type] || 'bg-gray-400']"></span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('payment.methods.' + method.type, method.type) }}
</span>
</div>
<div class="text-right">
<span class="text-sm font-medium text-gray-900 dark:text-white">
${{ method.amount.toFixed(2) }}
</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
({{ method.count }})
</span>
</div>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
<div
:class="['h-full rounded-full transition-all', barColorMap[method.type] || 'bg-gray-400']"
:style="{ width: barWidth(method.amount) + '%' }"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps<{
methods: { type: string; amount: number; count: number }[]
}>()
const colorMap: Record<string, string> = {
alipay: 'bg-blue-500',
wxpay: 'bg-green-500',
alipay_direct: 'bg-blue-400',
wxpay_direct: 'bg-green-400',
stripe: 'bg-purple-500',
}
const barColorMap: Record<string, string> = {
alipay: 'bg-blue-500',
wxpay: 'bg-green-500',
alipay_direct: 'bg-blue-400',
wxpay_direct: 'bg-green-400',
stripe: 'bg-purple-500',
}
const maxAmount = computed(() => {
if (!props.methods?.length) return 1
return Math.max(...props.methods.map(m => m.amount), 1)
})
function barWidth(amount: number): number {
return Math.min((amount / maxAmount.value) * 100, 100)
}
</script>
@@ -0,0 +1,52 @@
<template>
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('payment.admin.topUsers') }}
</h3>
<div
v-if="!users?.length"
class="flex h-32 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('payment.admin.noData') }}
</div>
<div v-else class="space-y-2">
<div
v-for="(user, idx) in users"
:key="user.user_id"
class="flex items-center justify-between rounded-lg px-3 py-2 hover:bg-gray-50 dark:hover:bg-dark-700"
>
<div class="flex items-center gap-3">
<span
:class="[
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
rankClass(idx),
]"
>
{{ idx + 1 }}
</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{{ user.email }}</span>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
${{ user.amount.toFixed(2) }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
defineProps<{
users: { user_id: number; email: string; amount: number }[]
}>()
function rankClass(idx: number): string {
if (idx === 0) return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
if (idx === 1) return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
if (idx === 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
return 'bg-gray-100 text-gray-500 dark:bg-dark-700 dark:text-gray-400'
}
</script>