Bug fixes: - Detached context for GetAccountConcurrencyBatch (prevent all-zero on request cancel) - Filter soft-deleted users in GetByGroupID - Stripe CSP policy (allow Stripe.js in script-src and frame-src) - WebSearch API key validation on save - RECHARGING status in payment result success check - Windows test fixes (logger Sync deadlock, config path escaping) Feature enhancements: - Webhook multi-instance dispatch (extractOutTradeNo + GetWebhookProvider) - EasyPay mobile H5 payment (device param + PayURL2) - SSE error propagation in WebSearch emulation - AccountStatsCost DTO field for admin usage logs - Plans sort by sort_order instead of created_at - UsageMapHook for streaming response usage data - apicompat Instructions field passthrough - EffectiveLoadFactor for ops concurrency/metrics - Usage billing RETURNING balance for notify system - BulkUpdate mixed channel warning with details - println to slog migration in auth cache - Wire ProviderSet cleanup - CI cache-dependency-path optimization Frontend: - Refund eligibility check per provider (canRequestRefund) - Plan sort_order editing - Dead code cleanup (simulate_claude_max, client_affinity) - GroupsView platform switch guard - channels features_config API type - UsageView account_stats_cost export
190 lines
7.8 KiB
Vue
190 lines
7.8 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<div class="space-y-4">
|
|
<!-- Filters -->
|
|
<div class="card p-4">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<Select v-model="currentFilter" :options="statusFilters" class="w-36" @change="fetchOrders" />
|
|
<div class="flex flex-1 items-center justify-end gap-2">
|
|
<button @click="fetchOrders" :disabled="loading" class="btn btn-secondary" :title="t('common.refresh')">
|
|
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
|
|
</button>
|
|
<button class="btn btn-primary" @click="router.push('/purchase')">{{ t('payment.result.backToRecharge') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<OrderTable :orders="orders" :loading="loading">
|
|
<template #actions="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" 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" />
|
|
<span>{{ t('payment.orders.cancel') }}</span>
|
|
</button>
|
|
<button v-if="canRequestRefund(row)" @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="dollar" size="sm" />
|
|
<span>{{ t('payment.orders.requestRefund') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</OrderTable>
|
|
|
|
<!-- Pagination -->
|
|
<Pagination
|
|
v-if="pagination.total > 0"
|
|
:page="pagination.page"
|
|
:total="pagination.total"
|
|
:page-size="pagination.page_size"
|
|
@update:page="handlePageChange"
|
|
@update:pageSize="handlePageSizeChange"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Cancel Confirm Dialog -->
|
|
<BaseDialog :show="!!cancelTargetId" :title="t('payment.orders.cancel')" width="narrow" @close="cancelTargetId = null">
|
|
<p class="text-sm text-gray-600 dark:text-gray-300">{{ t('payment.confirmCancel') }}</p>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button class="btn btn-secondary" @click="cancelTargetId = null">{{ t('common.cancel') }}</button>
|
|
<button class="btn btn-danger" :disabled="actionLoading" @click="confirmCancel">{{ actionLoading ? t('common.processing') : t('payment.orders.cancel') }}</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
|
|
<!-- Refund Dialog -->
|
|
<BaseDialog :show="!!refundTarget" :title="t('payment.orders.requestRefund')" @close="refundTarget = null">
|
|
<div v-if="refundTarget" class="space-y-4">
|
|
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
|
<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">#{{ refundTarget.id }}</span>
|
|
</div>
|
|
<div class="mt-2 flex justify-between text-sm">
|
|
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
|
<span class="text-gray-900 dark:text-white">${{ refundTarget.amount.toFixed(2) }}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="input-label">{{ t('payment.refundReason') }}</label>
|
|
<textarea v-model="refundReason" rows="3" class="input mt-1 w-full" :placeholder="t('payment.refundReasonPlaceholder')" />
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<div class="flex justify-end gap-3">
|
|
<button class="btn btn-secondary" @click="refundTarget = null">{{ t('common.cancel') }}</button>
|
|
<button class="btn btn-primary" :disabled="actionLoading || !refundReason.trim()" @click="confirmRefund">{{ actionLoading ? t('common.processing') : t('payment.orders.requestRefund') }}</button>
|
|
</div>
|
|
</template>
|
|
</BaseDialog>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAppStore } from '@/stores'
|
|
import { paymentAPI } from '@/api/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 OrderTable from '@/components/payment/OrderTable.vue'
|
|
|
|
const { t } = useI18n()
|
|
const router = useRouter()
|
|
const appStore = useAppStore()
|
|
|
|
const loading = ref(false)
|
|
const actionLoading = ref(false)
|
|
const orders = ref<PaymentOrder[]>([])
|
|
const refundEligibleProviders = ref<Set<string>>(new Set())
|
|
const currentFilter = ref('')
|
|
const cancelTargetId = ref<number | null>(null)
|
|
const refundTarget = ref<PaymentOrder | null>(null)
|
|
const refundReason = ref('')
|
|
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
|
|
|
|
const statusFilters = computed(() => [
|
|
{ value: '', label: t('common.all') },
|
|
{ value: 'PENDING', label: t('payment.status.pending') },
|
|
{ value: 'COMPLETED', label: t('payment.status.completed') },
|
|
{ value: 'FAILED', label: t('payment.status.failed') },
|
|
{ value: 'REFUNDED', label: t('payment.status.refunded') },
|
|
])
|
|
|
|
async function fetchOrders() {
|
|
loading.value = true
|
|
try {
|
|
const res = await paymentAPI.getMyOrders({
|
|
page: pagination.page,
|
|
page_size: pagination.page_size,
|
|
status: currentFilter.value || undefined,
|
|
})
|
|
orders.value = res.data.items || []
|
|
pagination.total = res.data.total || 0
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function handlePageChange(page: number) { pagination.page = page; fetchOrders() }
|
|
function handlePageSizeChange(size: number) { pagination.page_size = size; pagination.page = 1; fetchOrders() }
|
|
|
|
function handleCancel(orderId: number) { cancelTargetId.value = orderId }
|
|
|
|
async function confirmCancel() {
|
|
if (!cancelTargetId.value) return
|
|
actionLoading.value = true
|
|
try {
|
|
await paymentAPI.cancelOrder(cancelTargetId.value)
|
|
appStore.showSuccess(t('common.success'))
|
|
cancelTargetId.value = null
|
|
await fetchOrders()
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
} finally {
|
|
actionLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openRefundDialog(order: PaymentOrder) { refundTarget.value = order; refundReason.value = '' }
|
|
|
|
async function confirmRefund() {
|
|
if (!refundTarget.value || !refundReason.value.trim()) return
|
|
actionLoading.value = true
|
|
try {
|
|
await paymentAPI.requestRefund(refundTarget.value.id, { reason: refundReason.value.trim() })
|
|
appStore.showSuccess(t('common.success'))
|
|
refundTarget.value = null
|
|
refundReason.value = ''
|
|
await fetchOrders()
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
} finally {
|
|
actionLoading.value = false
|
|
}
|
|
}
|
|
|
|
function canRequestRefund(order: PaymentOrder): boolean {
|
|
if (order.status !== 'COMPLETED') return false
|
|
if (!order.provider_instance_id) return false
|
|
return refundEligibleProviders.value.has(order.provider_instance_id)
|
|
}
|
|
|
|
async function loadRefundEligibility() {
|
|
try {
|
|
const res = await paymentAPI.getRefundEligibleProviders()
|
|
refundEligibleProviders.value = new Set(res.data.provider_instance_ids || [])
|
|
} catch { /* ignore — default to hiding refund button */ }
|
|
}
|
|
|
|
onMounted(() => { fetchOrders(); loadRefundEligibility() })
|
|
</script>
|