feat(payment): add complete payment system with multi-provider support
Add a full payment and subscription system supporting EasyPay (Alipay/WeChat), Stripe, and direct Alipay/WeChat Pay providers with multi-instance load balancing.
This commit is contained in:
149
frontend/src/components/admin/payment/AdminOrderDetail.vue
Normal file
149
frontend/src/components/admin/payment/AdminOrderDetail.vue
Normal file
@@ -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', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status)
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
237
frontend/src/components/admin/payment/AdminOrderTable.vue
Normal file
237
frontend/src/components/admin/payment/AdminOrderTable.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<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-2">
|
||||
<button
|
||||
@click="emit('detail', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800/50 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
<span class="text-xs">{{ t('common.view') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'PENDING'"
|
||||
@click="emit('cancel', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-yellow-50 hover:text-yellow-600 dark:hover:bg-yellow-900/20 dark:hover:text-yellow-400"
|
||||
>
|
||||
<Icon name="x" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.orders.cancel') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'FAILED'"
|
||||
@click="emit('retry', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.admin.retry') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canRefund(row)"
|
||||
@click="emit('refund', row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
>
|
||||
<Icon name="dollar" size="sm" />
|
||||
<span class="text-xs">{{ t('payment.admin.refund') }}</span>
|
||||
</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') },
|
||||
{ value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') },
|
||||
{ value: 'REFUND_FAILED', label: t('payment.status.refund_failed') },
|
||||
])
|
||||
|
||||
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', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status)
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
234
frontend/src/components/admin/payment/AdminRefundDialog.vue
Normal file
234
frontend/src/components/admin/payment/AdminRefundDialog.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('payment.admin.refundOrder')"
|
||||
width="normal"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<form id="refund-form" @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Refund Request Info -->
|
||||
<div
|
||||
v-if="order?.refund_requested_at || order?.refund_request_reason"
|
||||
class="rounded-lg border border-violet-200 bg-violet-50 p-3 dark:border-violet-800 dark:bg-violet-900/20"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ t('payment.admin.refundRequestInfo') }}
|
||||
</div>
|
||||
<div v-if="order?.refund_requested_at" class="mt-2 flex justify-between text-sm">
|
||||
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestedAt') }}</span>
|
||||
<span class="text-violet-800 dark:text-violet-200">{{ formatDateTime(order.refund_requested_at) }}</span>
|
||||
</div>
|
||||
<div v-if="order?.refund_request_reason" class="mt-1 text-sm">
|
||||
<span class="text-violet-600 dark:text-violet-400">{{ t('payment.admin.refundRequestReason') }}:</span>
|
||||
<span class="ml-1 text-violet-800 dark:text-violet-200">{{ order.refund_request_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Info -->
|
||||
<div class="rounded-lg bg-gray-50 p-3 dark:bg-dark-700">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
|
||||
<span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
|
||||
<span class="font-medium text-red-600 dark:text-red-400">${{ actuallyRefunded.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deduct Balance -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="deduct-balance"
|
||||
v-model="form.deduct_balance"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<label for="deduct-balance" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('payment.admin.deductBalance') }}
|
||||
</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.deductBalanceHint') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- User Balance Info (when deduct_balance is checked) -->
|
||||
<div v-if="form.deduct_balance && userBalance != null" class="mt-3 grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
|
||||
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.userBalance') }}</div>
|
||||
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ userBalance.toFixed(2) }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
|
||||
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
|
||||
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insufficient balance warning -->
|
||||
<div
|
||||
v-if="form.deduct_balance && balanceInsufficient"
|
||||
class="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
|
||||
>
|
||||
{{ t('payment.admin.insufficientBalance') }}
|
||||
</div>
|
||||
|
||||
<!-- No deduction info -->
|
||||
<div
|
||||
v-if="!form.deduct_balance"
|
||||
class="mt-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
|
||||
>
|
||||
{{ t('payment.admin.noDeduction') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Refund Amount -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
:max="maxRefundable"
|
||||
class="input pl-7"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.refundReason') }}</label>
|
||||
<textarea
|
||||
v-model="form.reason"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('payment.admin.refundReasonPlaceholder')"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div
|
||||
v-if="warning"
|
||||
class="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300"
|
||||
>
|
||||
{{ warning }}
|
||||
</div>
|
||||
|
||||
<!-- Force Refund -->
|
||||
<div v-if="requireForce" class="flex items-center gap-2">
|
||||
<input
|
||||
id="force-refund"
|
||||
v-model="form.force"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<label for="force-refund" class="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
{{ t('payment.admin.forceRefund') }}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="emit('cancel')" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="refund-form"
|
||||
:disabled="submitting || form.amount <= 0 || (requireForce && !form.force)"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 dark:focus:ring-offset-dark-800"
|
||||
>
|
||||
{{ submitting ? t('common.processing') : t('payment.admin.confirmRefund') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
order: PaymentOrder | null
|
||||
submitting?: boolean
|
||||
userBalance?: number | null
|
||||
requireForce?: boolean
|
||||
warning?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
amount: 0,
|
||||
reason: '',
|
||||
deduct_balance: true,
|
||||
force: false,
|
||||
})
|
||||
|
||||
// In REFUND_REQUESTED status, refund_amount is the REQUESTED amount, not actually refunded.
|
||||
// Only PARTIALLY_REFUNDED / REFUNDED have real refund amounts.
|
||||
const actuallyRefunded = computed(() => {
|
||||
if (!props.order) return 0
|
||||
const s = props.order.status
|
||||
if (s === 'PARTIALLY_REFUNDED' || s === 'REFUNDED') return props.order.refund_amount || 0
|
||||
return 0
|
||||
})
|
||||
|
||||
const maxRefundable = computed(() => {
|
||||
if (!props.order) return 0
|
||||
return props.order.pay_amount - actuallyRefunded.value
|
||||
})
|
||||
|
||||
const balanceInsufficient = computed(() => {
|
||||
if (props.userBalance == null || !props.order) return false
|
||||
return props.userBalance < props.order.pay_amount
|
||||
})
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.order) {
|
||||
// For REFUND_REQUESTED, pre-fill with the requested amount
|
||||
if (props.order.status === 'REFUND_REQUESTED' && props.order.refund_amount) {
|
||||
form.amount = props.order.refund_amount
|
||||
} else {
|
||||
form.amount = maxRefundable.value
|
||||
}
|
||||
form.reason = props.order.refund_request_reason || ''
|
||||
form.deduct_balance = true
|
||||
form.force = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (form.amount <= 0 || form.amount > maxRefundable.value) return
|
||||
if (props.requireForce && !form.force) return
|
||||
emit('confirm', { ...form })
|
||||
}
|
||||
</script>
|
||||
99
frontend/src/components/admin/payment/DailyRevenueChart.vue
Normal file
99
frontend/src/components/admin/payment/DailyRevenueChart.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('payment.admin.dailyRevenue') }}
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<Line v-else-if="chartData" :data="chartData" :options="chartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('payment.admin.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
data: { date: string; amount: number; count: number }[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.data || props.data.length === 0) return null
|
||||
return {
|
||||
labels: props.data.map(d => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: t('payment.admin.revenue'),
|
||||
data: props.data.map(d => d.amount),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
{
|
||||
label: t('payment.admin.orderCount'),
|
||||
data: props.data.map(d => d.count),
|
||||
borderColor: 'rgb(16, 185, 129)',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
title: { display: true, text: t('payment.admin.revenue') },
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
title: { display: true, text: t('payment.admin.orderCount') },
|
||||
grid: { drawOnChartArea: false },
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: 'top' as const },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
77
frontend/src/components/admin/payment/OrderStatsCards.vue
Normal file
77
frontend/src/components/admin/payment/OrderStatsCards.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Revenue -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<Icon name="dollar" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.today_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.today_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Revenue -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<Icon name="creditCard" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.totalRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.total_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.total_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Orders -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<Icon name="chart" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayOrders') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats.today_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Average Amount -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
|
||||
<Icon name="chart" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.avgAmount') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.avg_amount) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
stats: DashboardStats
|
||||
}>()
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
</script>
|
||||
75
frontend/src/components/admin/payment/PaymentMethodChart.vue
Normal file
75
frontend/src/components/admin/payment/PaymentMethodChart.vue
Normal file
@@ -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>
|
||||
Reference in New Issue
Block a user