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:
239
frontend/src/views/admin/orders/AdminOrdersView.vue
Normal file
239
frontend/src/views/admin/orders/AdminOrdersView.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters -->
|
||||
<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="orderSearch" type="text" :placeholder="t('payment.admin.searchOrders')" class="input" @input="debounceLoadOrders" />
|
||||
</div>
|
||||
<Select v-model="orderFilters.status" :options="statusFilterOptions" class="w-36" @change="loadOrders" />
|
||||
<Select v-model="orderFilters.payment_type" :options="paymentTypeFilterOptions" class="w-40" @change="loadOrders" />
|
||||
<Select v-model="orderFilters.order_type" :options="orderTypeFilterOptions" class="w-36" @change="loadOrders" />
|
||||
<div class="flex flex-1 flex-wrap items-center justify-end gap-2">
|
||||
<button @click="loadOrders" :disabled="ordersLoading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="ordersLoading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<OrderTable :orders="orders" :loading="ordersLoading" show-user>
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="showOrderDetail(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-dark-600">
|
||||
<Icon name="eye" size="sm" />
|
||||
{{ t('common.view') }}
|
||||
</button>
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancelOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20">
|
||||
<Icon name="x" size="sm" />
|
||||
{{ t('payment.orders.cancel') }}
|
||||
</button>
|
||||
<button v-if="row.status === 'FAILED'" @click="handleRetryOrder(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/20">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('payment.admin.retry') }}
|
||||
</button>
|
||||
<template v-if="row.status === 'REFUND_REQUESTED'">
|
||||
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">${{ row.refund_amount.toFixed(2) }}</span>
|
||||
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="check" size="sm" />
|
||||
{{ t('payment.admin.approveRefund') }}
|
||||
</button>
|
||||
</template>
|
||||
<button v-else-if="row.status === 'REFUND_FAILED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('payment.admin.retryRefund') }}
|
||||
</button>
|
||||
<button v-else-if="row.status === 'COMPLETED' || row.status === 'PARTIALLY_REFUNDED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
<Icon name="dollar" size="sm" />
|
||||
{{ t('payment.admin.refund') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</OrderTable>
|
||||
<Pagination v-if="orderPagination.total > 0" :page="orderPagination.page" :total="orderPagination.total" :page-size="orderPagination.page_size" @update:page="handleOrderPageChange" @update:pageSize="handleOrderPageSizeChange" />
|
||||
</div>
|
||||
|
||||
<!-- Order Detail Dialog -->
|
||||
<BaseDialog :show="showDetailDialog" :title="t('payment.admin.orderDetail')" width="wide" @close="showDetailDialog = false">
|
||||
<div v-if="selectedOrder" 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">#{{ selectedOrder.id }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
|
||||
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></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">${{ selectedOrder.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">${{ selectedOrder.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.' + selectedOrder.payment_type, selectedOrder.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">{{ (selectedOrder.fee_rate * 100).toFixed(1) }}%</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(selectedOrder.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(selectedOrder.expires_at) }}</p></div>
|
||||
<div v-if="selectedOrder.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(selectedOrder.paid_at) }}</p></div>
|
||||
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">${{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
|
||||
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
|
||||
<!-- Refund request info -->
|
||||
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||
<p class="mb-2 text-xs font-medium text-purple-600 dark:text-purple-400">{{ t('payment.admin.refundRequestInfo') }}</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedAt') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.refund_requested_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestedBy') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">#{{ selectedOrder.refund_requested_by }}</p>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundRequestReason') }}</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_request_reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Audit Logs -->
|
||||
<div v-if="orderAuditLogs.length > 0" class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<p class="mb-2 text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.auditLogs') }}</p>
|
||||
<div class="max-h-48 space-y-2 overflow-y-auto">
|
||||
<div v-for="log in orderAuditLogs" :key="log.id" class="rounded-lg border border-gray-100 bg-gray-50 p-2.5 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ log.action }}</span>
|
||||
<span class="text-xs text-gray-400">{{ formatDateTime(log.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="log.detail" class="mt-1 break-all text-xs text-gray-500 dark:text-gray-400">{{ log.detail }}</div>
|
||||
<div v-if="log.operator" class="mt-1 text-xs text-gray-400">{{ t('payment.admin.operator') }}: {{ log.operator }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<AdminRefundDialog :show="showRefundDialog" :order="selectedOrder" :submitting="refundSubmitting" @confirm="handleRefund" @cancel="showRefundDialog = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { PaymentOrder } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import AdminRefundDialog from '@/components/admin/payment/AdminRefundDialog.vue'
|
||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||
import OrderTable from '@/components/payment/OrderTable.vue'
|
||||
|
||||
interface AuditLog {
|
||||
id: number
|
||||
action: string
|
||||
detail: string | null
|
||||
operator: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const ordersLoading = ref(false)
|
||||
const orders = ref<PaymentOrder[]>([])
|
||||
const orderSearch = ref('')
|
||||
const orderFilters = reactive({ status: '', payment_type: '', order_type: '' })
|
||||
const orderPagination = reactive({ page: 1, page_size: 20, total: 0 })
|
||||
const selectedOrder = ref<PaymentOrder | null>(null)
|
||||
const showDetailDialog = ref(false)
|
||||
const showRefundDialog = ref(false)
|
||||
const refundSubmitting = ref(false)
|
||||
const orderAuditLogs = ref<AuditLog[]>([])
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function debounceLoadOrders() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => loadOrders(), 300)
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
ordersLoading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getOrders({
|
||||
page: orderPagination.page, page_size: orderPagination.page_size,
|
||||
keyword: orderSearch.value || undefined, status: orderFilters.status || undefined,
|
||||
payment_type: orderFilters.payment_type || undefined, order_type: orderFilters.order_type || undefined,
|
||||
})
|
||||
orders.value = res.data.items || []
|
||||
orderPagination.total = res.data.total || 0
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally { ordersLoading.value = false }
|
||||
}
|
||||
|
||||
function handleOrderPageChange(page: number) { orderPagination.page = page; loadOrders() }
|
||||
function handleOrderPageSizeChange(size: number) { orderPagination.page_size = size; orderPagination.page = 1; loadOrders() }
|
||||
|
||||
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') },
|
||||
])
|
||||
|
||||
async function showOrderDetail(order: PaymentOrder) {
|
||||
selectedOrder.value = order
|
||||
orderAuditLogs.value = []
|
||||
showDetailDialog.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getOrder(order.id)
|
||||
const data = res.data as unknown as Record<string, unknown>
|
||||
if (data.order) selectedOrder.value = data.order as PaymentOrder
|
||||
orderAuditLogs.value = ((data.auditLogs || data.audit_logs || []) as unknown) as AuditLog[]
|
||||
} catch (_err: unknown) { /* keep cached order data */ }
|
||||
}
|
||||
|
||||
async function handleCancelOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
async function handleRetryOrder(order: PaymentOrder) {
|
||||
try { await adminPaymentAPI.retryRecharge(order.id); appStore.showSuccess(t('payment.admin.retrySuccess')); loadOrders() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
|
||||
|
||||
async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }) {
|
||||
if (!selectedOrder.value) return
|
||||
refundSubmitting.value = true
|
||||
try {
|
||||
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
|
||||
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { refundSubmitting.value = false }
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string { if (!dateStr) return '-'; return new Date(dateStr).toLocaleString() }
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
</script>
|
||||
121
frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
Normal file
121
frontend/src/views/admin/orders/AdminPaymentDashboardView.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Day Switcher -->
|
||||
<div class="flex items-center justify-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<button
|
||||
v-for="d in DAYS_OPTIONS"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs font-medium transition-colors first:rounded-l-lg last:rounded-r-lg"
|
||||
:class="days === d
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700'"
|
||||
@click="days = d"
|
||||
>
|
||||
{{ d }}{{ t('payment.admin.daySuffix') }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="loadDashboard" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Content -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<template v-else-if="stats">
|
||||
<OrderStatsCards :stats="stats" />
|
||||
<DailyRevenueChart :data="stats.daily_series || []" :loading="loading" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<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="!stats.payment_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 stats.payment_methods" :key="method.type" class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['inline-block h-3 w-3 rounded-full', methodColor(method.type)]"></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>
|
||||
</div>
|
||||
<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="!stats.top_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 stats.top_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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import type { DashboardStats } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OrderStatsCards from '@/components/admin/payment/OrderStatsCards.vue'
|
||||
import DailyRevenueChart from '@/components/admin/payment/DailyRevenueChart.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const DAYS_OPTIONS = [7, 30, 90] as const
|
||||
const days = ref<number>(30)
|
||||
const loading = ref(false)
|
||||
const stats = ref<DashboardStats | null>(null)
|
||||
|
||||
function methodColor(type: string): string {
|
||||
const c: 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',
|
||||
}
|
||||
return c[type] || 'bg-gray-400'
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getDashboard(days.value)
|
||||
stats.value = res.data
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(days, () => loadDashboard())
|
||||
onMounted(() => loadDashboard())
|
||||
</script>
|
||||
317
frontend/src/views/admin/orders/AdminPaymentPlansView.vue
Normal file
317
frontend/src/views/admin/orders/AdminPaymentPlansView.vue
Normal file
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-4">
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button @click="loadPlans" :disabled="plansLoading" class="btn btn-secondary" :title="t('common.refresh')">
|
||||
<Icon name="refresh" size="md" :class="plansLoading ? 'animate-spin' : ''" />
|
||||
</button>
|
||||
<button @click="openPlanEdit(null)" class="btn btn-primary">{{ t('payment.admin.createPlan') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
<DataTable :columns="planColumns" :data="plans" :loading="plansLoading">
|
||||
<template #cell-name="{ value, row }">
|
||||
<span class="text-sm font-medium" :class="getPlanNameClass(row.group_id)">{{ value }}</span>
|
||||
</template>
|
||||
<template #cell-group_id="{ value }">
|
||||
<span v-if="isGroupMissing(value)" class="text-sm">
|
||||
<span class="text-gray-400">#{{ value }}</span>
|
||||
<span class="ml-1 badge badge-danger">{{ t('payment.admin.groupMissing') }}</span>
|
||||
</span>
|
||||
<GroupBadge
|
||||
v-else-if="getGroup(value)"
|
||||
:name="getGroup(value)!.name"
|
||||
:platform="getGroup(value)!.platform"
|
||||
:rate-multiplier="getGroup(value)!.rate_multiplier"
|
||||
/>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
<template #cell-price="{ value, row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
|
||||
<span v-if="row.original_price" class="ml-1 text-xs text-gray-400 line-through">${{ row.original_price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-validity_days="{ value, row }">
|
||||
<span class="text-sm">{{ value }} {{ t('payment.admin.' + (row.validity_unit || 'days')) }}</span>
|
||||
</template>
|
||||
<template #cell-for_sale="{ value, row }">
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
value ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="toggleForSale(row)"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
value ? 'translate-x-4' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="openPlanEdit(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="edit" size="sm" />
|
||||
<span class="text-xs">{{ t('common.edit') }}</span>
|
||||
</button>
|
||||
<button @click="confirmDeletePlan(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="trash" size="sm" />
|
||||
<span class="text-xs">{{ t('common.delete') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Plan Edit Dialog -->
|
||||
<BaseDialog :show="showPlanDialog" :title="editingPlan ? t('payment.admin.editPlan') : t('payment.admin.createPlan')" width="wide" @close="showPlanDialog = false">
|
||||
<form id="plan-form" @submit.prevent="handleSavePlan" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.planName') }}</label>
|
||||
<input v-model="planForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.group') }}</label>
|
||||
<Select v-model="planForm.group_id" :options="groupOptions" class="w-full">
|
||||
<template #selected="{ option }">
|
||||
<span v-if="option?.platform" :class="platformTextClass(String(option.platform))">{{ option.label }}</span>
|
||||
<span v-else>{{ option?.label || t('payment.admin.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<span class="flex-1 truncate text-left" :class="option.platform ? platformTextClass(String(option.platform)) : ''">{{ option.label }}</span>
|
||||
<Icon v-if="selected" name="check" size="sm" class="text-primary-500" :stroke-width="2" />
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Info Preview -->
|
||||
<div v-if="selectedGroupInfo" class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<GroupBadge :name="selectedGroupInfo.name" :platform="selectedGroupInfo.platform" :rate-multiplier="selectedGroupInfo.rate_multiplier" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.dailyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.daily_limit_usd != null ? '$' + selectedGroupInfo.daily_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.weeklyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.weekly_limit_usd != null ? '$' + selectedGroupInfo.weekly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
<div><span class="text-gray-500">{{ t('payment.admin.monthlyLimit') }}:</span> <span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ selectedGroupInfo.monthly_limit_usd != null ? '$' + selectedGroupInfo.monthly_limit_usd : t('payment.admin.unlimited') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div><label class="input-label">{{ t('payment.admin.planDescription') }}</label><textarea v-model="planForm.description" rows="2" class="input"></textarea></div>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.price') }}</label><input v-model.number="planForm.price" type="number" step="0.01" min="0" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.originalPrice') }}</label><input v-model.number="planForm.original_price" type="number" step="0.01" min="0" class="input" /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.sortOrder') }}</label><input v-model.number="planForm.sort_order" type="number" min="0" class="input" /></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="input-label">{{ t('payment.admin.validityDays') }}</label><input v-model.number="planForm.validity_days" type="number" min="1" class="input" required /></div>
|
||||
<div><label class="input-label">{{ t('payment.admin.validityUnit') }}</label><Select v-model="planForm.validity_unit" :options="validityUnitOptions" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.features') }}</label>
|
||||
<textarea v-model="planFeaturesText" rows="3" class="input" :placeholder="t('payment.admin.featuresPlaceholder')"></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.featuresHint') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.admin.forSale') }}</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
planForm.for_sale ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
|
||||
]"
|
||||
@click="planForm.for_sale = !planForm.for_sale"
|
||||
>
|
||||
<span :class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
planForm.for_sale ? 'translate-x-5' : 'translate-x-0'
|
||||
]" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" @click="showPlanDialog = false" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="plan-form" :disabled="planSaving" class="btn btn-primary">{{ planSaving ? t('common.saving') : t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog :show="showDeletePlanDialog" :title="t('payment.admin.deletePlan')" :message="t('payment.admin.deletePlanConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeletePlan" @cancel="showDeletePlanDialog = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminPaymentAPI } from '@/api/admin/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import adminAPI from '@/api/admin'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import type { AdminGroup } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import { platformTextClass } from '@/utils/platformColors'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// ==================== Groups ====================
|
||||
|
||||
const groups = ref<AdminGroup[]>([])
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await adminAPI.groups.getAll()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function getGroup(id: number): AdminGroup | undefined {
|
||||
return groups.value.find(g => g.id === id)
|
||||
}
|
||||
|
||||
function isGroupMissing(id: number): boolean {
|
||||
return id > 0 && !groups.value.find(g => g.id === id)
|
||||
}
|
||||
|
||||
function getPlanNameClass(groupId: number): string {
|
||||
const group = getGroup(groupId)
|
||||
return group ? platformTextClass(group.platform) : 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
const groupOptions = computed(() => [
|
||||
{ value: 0, label: t('payment.admin.selectGroup'), platform: '' },
|
||||
...groups.value
|
||||
.filter(g => g.subscription_type === 'subscription')
|
||||
.map(g => ({
|
||||
value: g.id,
|
||||
label: `${g.name} — ${g.platform} (${g.rate_multiplier}x)`,
|
||||
platform: g.platform,
|
||||
})),
|
||||
])
|
||||
|
||||
const selectedGroupInfo = computed(() => {
|
||||
if (!planForm.group_id) return null
|
||||
return groups.value.find(g => g.id === planForm.group_id) || null
|
||||
})
|
||||
|
||||
// ==================== Plans ====================
|
||||
|
||||
const plansLoading = ref(false)
|
||||
const plans = ref<SubscriptionPlan[]>([])
|
||||
const showPlanDialog = ref(false)
|
||||
const showDeletePlanDialog = ref(false)
|
||||
const planSaving = ref(false)
|
||||
const editingPlan = ref<SubscriptionPlan | null>(null)
|
||||
const deletingPlanId = ref<number | null>(null)
|
||||
const planForm = reactive({ name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
const planFeaturesText = ref('')
|
||||
|
||||
const validityUnitOptions = computed(() => [
|
||||
{ value: 'days', label: t('payment.admin.days') },
|
||||
{ value: 'weeks', label: t('payment.admin.weeks') },
|
||||
{ value: 'months', label: t('payment.admin.months') },
|
||||
])
|
||||
|
||||
const planColumns = computed((): Column[] => [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: t('payment.admin.planName') },
|
||||
{ key: 'group_id', label: t('payment.admin.group') },
|
||||
{ key: 'price', label: t('payment.admin.price') },
|
||||
{ key: 'validity_days', label: t('payment.admin.validityDays') },
|
||||
{ key: 'for_sale', label: t('payment.admin.forSale') },
|
||||
{ key: 'sort_order', label: t('payment.admin.sortOrder') },
|
||||
{ key: 'actions', label: t('common.actions') },
|
||||
])
|
||||
|
||||
async function loadPlans() {
|
||||
plansLoading.value = true
|
||||
try {
|
||||
const res = await adminPaymentAPI.getPlans()
|
||||
// Backend returns features as newline-separated string; parse to array
|
||||
plans.value = (res.data || []).map((p: Omit<SubscriptionPlan, 'features'> & { features: string | string[] }) => ({
|
||||
...p,
|
||||
features: typeof p.features === 'string'
|
||||
? p.features.split('\n').map((f: string) => f.trim()).filter(Boolean)
|
||||
: (p.features || []),
|
||||
}))
|
||||
}
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { plansLoading.value = false }
|
||||
}
|
||||
|
||||
function openPlanEdit(plan: SubscriptionPlan | null) {
|
||||
editingPlan.value = plan
|
||||
if (plan) {
|
||||
Object.assign(planForm, { name: plan.name, group_id: plan.group_id, description: plan.description, price: plan.price, original_price: plan.original_price || 0, validity_days: plan.validity_days, validity_unit: plan.validity_unit || 'days', for_sale: plan.for_sale, sort_order: plan.sort_order })
|
||||
planFeaturesText.value = (plan.features || []).join('\n')
|
||||
} else {
|
||||
Object.assign(planForm, { name: '', group_id: 0, description: '', price: 0, original_price: 0, validity_days: 30, validity_unit: 'days', for_sale: true, sort_order: 0 })
|
||||
planFeaturesText.value = ''
|
||||
}
|
||||
showPlanDialog.value = true
|
||||
}
|
||||
|
||||
/** Build request payload with snake_case keys matching backend JSON tags */
|
||||
function buildPlanPayload() {
|
||||
const features = planFeaturesText.value.split('\n').map(f => f.trim()).filter(Boolean).join('\n')
|
||||
return {
|
||||
name: planForm.name,
|
||||
group_id: planForm.group_id,
|
||||
description: planForm.description,
|
||||
price: planForm.price,
|
||||
original_price: planForm.original_price || 0,
|
||||
validity_days: planForm.validity_days,
|
||||
validity_unit: planForm.validity_unit,
|
||||
for_sale: planForm.for_sale,
|
||||
sort_order: planForm.sort_order,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePlan() {
|
||||
planSaving.value = true
|
||||
try {
|
||||
const data = buildPlanPayload()
|
||||
if (editingPlan.value) { await adminPaymentAPI.updatePlan(editingPlan.value.id, data) }
|
||||
else { await adminPaymentAPI.createPlan(data) }
|
||||
appStore.showSuccess(t('common.saved')); showPlanDialog.value = false; loadPlans()
|
||||
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
finally { planSaving.value = false }
|
||||
}
|
||||
|
||||
/** Quick toggle for_sale from the list */
|
||||
async function toggleForSale(plan: SubscriptionPlan) {
|
||||
try {
|
||||
await adminPaymentAPI.updatePlan(plan.id, { for_sale: !plan.for_sale })
|
||||
plan.for_sale = !plan.for_sale
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeletePlan(plan: SubscriptionPlan) { deletingPlanId.value = plan.id; showDeletePlanDialog.value = true }
|
||||
async function handleDeletePlan() {
|
||||
if (!deletingPlanId.value) return
|
||||
try { await adminPaymentAPI.deletePlan(deletingPlanId.value); appStore.showSuccess(t('common.deleted')); showDeletePlanDialog.value = false; loadPlans() }
|
||||
catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadPlans()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user