"backend/internal/handler/vscode:/vscode.git/clone" did not exist on "66e15a54a4ace8af69493b8eba08771a046e966b"
Commit c520de11 authored by qingyuzhang's avatar qingyuzhang
Browse files

Merge branch 'main' of github.com:Wei-Shaw/sub2api into qingyu/fix-smooth-sidebar-collapse

# Conflicts:
#	frontend/src/components/layout/AppSidebar.vue
parents 07d2add6 97f14b7a
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import AnnouncementReadStatusDialog from '../AnnouncementReadStatusDialog.vue'
const { getReadStatus, showError } = vi.hoisted(() => ({
getReadStatus: vi.fn(),
showError: vi.fn(),
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
announcements: {
getReadStatus,
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError,
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/composables/usePersistedPageSize', () => ({
getPersistedPageSize: () => 20,
}))
const BaseDialogStub = {
props: ['show', 'title', 'width'],
emits: ['close'],
template: '<div><slot /><slot name="footer" /></div>',
}
describe('AnnouncementReadStatusDialog', () => {
beforeEach(() => {
getReadStatus.mockReset()
showError.mockReset()
vi.useFakeTimers()
})
it('closes by aborting active requests and clearing debounced reloads', async () => {
let activeSignal: AbortSignal | undefined
getReadStatus.mockImplementation(async (...args: any[]) => {
activeSignal = args[4]?.signal
return new Promise(() => {})
})
const wrapper = mount(AnnouncementReadStatusDialog, {
props: {
show: false,
announcementId: 1,
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
DataTable: true,
Pagination: true,
Icon: true,
},
},
})
await wrapper.setProps({ show: true })
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
expect(activeSignal?.aborted).toBe(false)
const setupState = (wrapper.vm as any).$?.setupState
setupState.search = 'alice'
setupState.handleSearch()
setupState.handleClose()
await flushPromises()
expect(activeSignal?.aborted).toBe(true)
expect(wrapper.emitted('close')).toHaveLength(1)
vi.advanceTimersByTime(350)
await flushPromises()
expect(getReadStatus).toHaveBeenCalledTimes(1)
})
})
...@@ -196,7 +196,6 @@ ...@@ -196,7 +196,6 @@
:total="localEntries.length" :total="localEntries.length"
:page="currentPage" :page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 50]"
@update:page="currentPage = $event" @update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange" @update:pageSize="handlePageSizeChange"
/> />
......
<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'
import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils'
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 canRefund(order: PaymentOrder): boolean {
return canRefundStatus(order.status)
}
function formatDateTime(dateStr: string): string {
return formatOrderDateTime(dateStr)
}
</script>
<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="canRefundRow(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'
import { statusBadgeClass, canRefund, formatOrderDateTime } from '@/components/payment/orderUtils'
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 = computed<Column[]>(() => [
{ key: 'id', label: t('payment.orders.orderId') },
{ key: 'user_id', label: t('payment.orders.userId') },
{ key: 'amount', label: t('payment.orders.amount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') },
{ key: 'order_type', label: t('payment.orders.orderType') },
{ key: 'created_at', label: t('payment.orders.createdAt') },
{ key: 'actions', label: t('payment.orders.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 canRefundRow(order: PaymentOrder): boolean {
return canRefund(order.status)
}
function formatDateTime(dateStr: string): string {
return formatOrderDateTime(dateStr)
}
</script>
<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'
import { formatOrderDateTime } from '@/components/payment/orderUtils'
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 {
return formatOrderDateTime(dateStr)
}
function handleSubmit() {
if (form.amount <= 0 || form.amount > maxRefundable.value) return
if (props.requireForce && !form.force) return
emit('confirm', { ...form })
}
</script>
<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>
<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>
<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>
<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>
<template> <template>
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="overflow-auto"> <div class="overflow-auto">
<DataTable :columns="columns" :data="data" :loading="loading"> <DataTable
:columns="columns"
:data="data"
:loading="loading"
:server-side-sort="serverSideSort"
:default-sort-key="defaultSortKey"
:default-sort-order="defaultSortOrder"
@sort="(key, order) => $emit('sort', key, order)"
>
<template #cell-user="{ row }"> <template #cell-user="{ row }">
<div class="text-sm"> <div class="text-sm">
<button <button
...@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue' ...@@ -334,9 +342,27 @@ import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { AdminUsageLog } from '@/types' import type { AdminUsageLog } from '@/types'
import type { Column } from '@/components/common/types'
interface Props {
data: AdminUsageLog[]
loading?: boolean
columns: Column[]
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
}
defineProps(['data', 'loading', 'columns']) withDefaults(defineProps<Props>(), {
defineEmits(['userClick']) loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n() const { t } = useI18n()
// Tooltip state - cost // Tooltip state - cost
......
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
'is-scrollable': isScrollable 'is-scrollable': isScrollable
}" }"
> >
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700"> <table class="w-full min-w-max divide-y divide-gray-200 dark:divide-dark-700">
<thead class="table-header bg-gray-50 dark:bg-dark-800"> <thead class="table-header bg-gray-50 dark:bg-dark-800">
<tr> <tr>
<th <th
...@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col { ...@@ -797,3 +797,62 @@ tbody tr:hover .sticky-col {
background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent); background: linear-gradient(to left, rgba(0, 0, 0, 0.2), transparent);
} }
</style> </style>
<style>
/* ==========================================================================
终极悬浮滚动条防丢器 (Sledgehammer Override)
绕过 style.css 中 `* { scrollbar-color: transparent }` 的全局悬停隐身诅咒!
========================================================================== */
/* 1. 废除全局针对所有元素的 scrollbar-width 设定,拿回 Chrome/Safari 下 Webkit 滚动条规则的控制权! */
.table-wrapper {
scrollbar-width: auto !important; /* 阻止 Chrome 121 退化到原生 Mac 闪隐滚动条 */
}
/* 2. 重写 Webkit 滚动层,全部加上 !important 强制覆盖透明悬停陷阱 */
.table-wrapper::-webkit-scrollbar {
height: 12px !important;
width: 12px !important;
display: block !important;
background-color: transparent !important;
}
.table-wrapper::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.03) !important;
border-radius: 6px !important;
margin: 0 4px !important;
}
.dark .table-wrapper::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* 常驻、不透明的滑块,无视鼠标是否 hover 都在那! */
.table-wrapper::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.75) !important;
border-radius: 6px !important;
border: 2px solid transparent !important;
background-clip: padding-box !important;
-webkit-appearance: none !important;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background-color: rgba(75, 85, 99, 0.9) !important;
}
.dark .table-wrapper::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.75) !important;
}
.dark .table-wrapper::-webkit-scrollbar-thumb:hover {
background-color: rgba(209, 213, 219, 0.9) !important;
}
/* 3. 仅给真正的 Firefox 留的后路 */
@supports (-moz-appearance:none) {
.table-wrapper {
scrollbar-width: thin !important;
scrollbar-color: rgba(156, 163, 175, 0.5) rgba(0, 0, 0, 0.03) !important;
}
.dark .table-wrapper {
scrollbar-color: rgba(75, 85, 99, 0.5) rgba(255, 255, 255, 0.05) !important;
}
}
</style>
...@@ -122,7 +122,7 @@ import { computed, ref } from 'vue' ...@@ -122,7 +122,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import Select from './Select.vue' import Select from './Select.vue'
import { setPersistedPageSize } from '@/composables/usePersistedPageSize' import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences'
const { t } = useI18n() const { t } = useI18n()
...@@ -141,7 +141,7 @@ interface Emits { ...@@ -141,7 +141,7 @@ interface Emits {
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
pageSizeOptions: () => [10, 20, 50, 100], pageSizeOptions: () => getConfiguredTablePageSizeOptions(),
showPageSizeSelector: true, showPageSizeSelector: true,
showJump: false showJump: false
}) })
...@@ -161,7 +161,14 @@ const toItem = computed(() => { ...@@ -161,7 +161,14 @@ const toItem = computed(() => {
}) })
const pageSizeSelectOptions = computed(() => { const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map((size) => ({ const options = Array.from(
new Set([
...getConfiguredTablePageSizeOptions(),
normalizeTablePageSize(props.pageSize)
])
).sort((a, b) => a - b)
return options.map((size) => ({
value: size, value: size,
label: String(size) label: String(size)
})) }))
...@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => { ...@@ -216,8 +223,7 @@ const goToPage = (newPage: number) => {
const handlePageSizeChange = (value: string | number | boolean | null) => { const handlePageSizeChange = (value: string | number | boolean | null) => {
if (value === null || typeof value === 'boolean') return if (value === null || typeof value === 'boolean') return
const newPageSize = typeof value === 'string' ? parseInt(value) : value const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value)
setPersistedPageSize(newPageSize)
emit('update:pageSize', newPageSize) emit('update:pageSize', newPageSize)
} }
......
...@@ -27,28 +27,70 @@ ...@@ -27,28 +27,70 @@
<template v-if="isAdmin"> <template v-if="isAdmin">
<!-- Admin Section --> <!-- Admin Section -->
<div class="sidebar-section"> <div class="sidebar-section">
<router-link <template v-for="item in adminNavItems" :key="item.path">
v-for="item in adminNavItems" <!-- Collapsible group (has children) -->
:key="item.path" <template v-if="item.children?.length">
:to="item.path" <button
class="sidebar-link mb-1" type="button"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }" class="sidebar-link mb-1 w-full"
:title="sidebarCollapsed ? item.label : undefined" :class="{
:id=" 'sidebar-link-active': isGroupActive(item) && !isGroupExpanded(item),
item.path === '/admin/accounts' 'sidebar-link-collapsed': sidebarCollapsed
? 'sidebar-channel-manage' }"
: item.path === '/admin/groups' :title="sidebarCollapsed ? item.label : undefined"
? 'sidebar-group-manage' @click="sidebarCollapsed ? undefined : toggleGroup(item)"
: item.path === '/admin/redeem' >
? 'sidebar-wallet' <component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
: undefined <span
" class="sidebar-label sidebar-label-flex"
@click="handleMenuItemClick(item.path)" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }"
> :aria-hidden="sidebarCollapsed ? 'true' : 'false'"
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span> >
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" /> <span class="min-w-0 truncate">{{ item.label }}</span>
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span> <ChevronDownIcon
</router-link> class="h-4 w-4 flex-shrink-0 transition-transform duration-200"
:class="isGroupExpanded(item) ? 'rotate-180' : ''"
/>
</span>
</button>
<!-- Children -->
<div v-if="!sidebarCollapsed && isGroupExpanded(item)" class="mb-1 ml-4 border-l border-gray-200 pl-2 dark:border-dark-600">
<router-link
v-for="child in item.children"
:key="child.path"
:to="child.path"
class="sidebar-link mb-0.5 py-1.5 text-sm"
:class="{ 'sidebar-link-active': route.path === child.path }"
@click="handleMenuItemClick(child.path)"
>
<component :is="child.icon" class="h-4 w-4 flex-shrink-0" />
<span>{{ child.label }}</span>
</router-link>
</div>
</template>
<!-- Normal item (no children) -->
<router-link
v-else
:to="item.path"
class="sidebar-link mb-1"
:class="{ 'sidebar-link-active': isActive(item.path), 'sidebar-link-collapsed': sidebarCollapsed }"
:title="sidebarCollapsed ? item.label : undefined"
:id="
item.path === '/admin/accounts'
? 'sidebar-channel-manage'
: item.path === '/admin/groups'
? 'sidebar-group-manage'
: item.path === '/admin/redeem'
? 'sidebar-wallet'
: undefined
"
@click="handleMenuItemClick(item.path)"
>
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="sanitizeSvg(item.iconSvg)"></span>
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
<span class="sidebar-label" :class="{ 'sidebar-label-collapsed': sidebarCollapsed }" :aria-hidden="sidebarCollapsed ? 'true' : 'false'">{{ item.label }}</span>
</router-link>
</template>
</div> </div>
<!-- Personal Section for Admin (hidden in simple mode) --> <!-- Personal Section for Admin (hidden in simple mode) -->
...@@ -151,6 +193,7 @@ interface NavItem { ...@@ -151,6 +193,7 @@ interface NavItem {
icon: unknown icon: unknown
iconSvg?: string iconSvg?: string
hideInSimpleMode?: boolean hideInSimpleMode?: boolean
children?: NavItem[]
} }
const { t } = useI18n() const { t } = useI18n()
...@@ -166,6 +209,9 @@ const mobileOpen = computed(() => appStore.mobileOpen) ...@@ -166,6 +209,9 @@ const mobileOpen = computed(() => appStore.mobileOpen)
const isAdmin = computed(() => authStore.isAdmin) const isAdmin = computed(() => authStore.isAdmin)
const isDark = ref(document.documentElement.classList.contains('dark')) const isDark = ref(document.documentElement.classList.contains('dark'))
// Track which parent nav groups are expanded
const expandedGroups = ref<Set<string>>(new Set())
// Site settings from appStore (cached, no flicker) // Site settings from appStore (cached, no flicker)
const siteName = computed(() => appStore.siteName) const siteName = computed(() => appStore.siteName)
const siteLogo = computed(() => appStore.siteLogo) const siteLogo = computed(() => appStore.siteLogo)
...@@ -458,6 +504,36 @@ const ChevronDoubleLeftIcon = { ...@@ -458,6 +504,36 @@ const ChevronDoubleLeftIcon = {
) )
} }
const OrderIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z'
})
]
)
}
const OrderListIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z'
})
]
)
}
const ChevronDoubleRightIcon = { const ChevronDoubleRightIcon = {
render: () => render: () =>
h( h(
...@@ -473,6 +549,21 @@ const ChevronDoubleRightIcon = { ...@@ -473,6 +549,21 @@ const ChevronDoubleRightIcon = {
) )
} }
const ChevronDownIcon = {
render: () =>
h(
'svg',
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
[
h('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
d: 'm19.5 8.25-7.5 7.5-7.5-7.5'
})
]
)
}
// User navigation items (for regular users) // User navigation items (for regular users)
const userNavItems = computed((): NavItem[] => { const userNavItems = computed((): NavItem[] => {
const items: NavItem[] = [ const items: NavItem[] = [
...@@ -480,14 +571,24 @@ const userNavItems = computed((): NavItem[] => { ...@@ -480,14 +571,24 @@ const userNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled ...(appStore.cachedPublicSettings?.payment_enabled
? [ ? [
{ {
path: '/purchase', path: '/purchase',
label: t('nav.buySubscription'), label: t('nav.buySubscription'),
icon: RechargeSubscriptionIcon, icon: RechargeSubscriptionIcon,
hideInSimpleMode: true hideInSimpleMode: true
} },
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
] ]
: []), : []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
...@@ -508,14 +609,24 @@ const personalNavItems = computed((): NavItem[] => { ...@@ -508,14 +609,24 @@ const personalNavItems = computed((): NavItem[] => {
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon }, { path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.purchase_subscription_enabled ...(appStore.cachedPublicSettings?.payment_enabled
? [ ? [
{ {
path: '/purchase', path: '/purchase',
label: t('nav.buySubscription'), label: t('nav.buySubscription'),
icon: RechargeSubscriptionIcon, icon: RechargeSubscriptionIcon,
hideInSimpleMode: true hideInSimpleMode: true
} },
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
] ]
: []), : []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
...@@ -560,6 +671,21 @@ const adminNavItems = computed((): NavItem[] => { ...@@ -560,6 +671,21 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon }, { path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true }, { path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true }, { path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
...(adminSettingsStore.paymentEnabled
? [
{
path: '/admin/orders',
label: t('nav.orderManagement'),
icon: OrderIcon,
hideInSimpleMode: true,
children: [
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
],
},
]
: []),
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon } { path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
] ]
...@@ -621,6 +747,23 @@ function isActive(path: string): boolean { ...@@ -621,6 +747,23 @@ function isActive(path: string): boolean {
return route.path === path || route.path.startsWith(path + '/') return route.path === path || route.path.startsWith(path + '/')
} }
function isGroupActive(item: NavItem): boolean {
if (!item.children) return false
return item.children.some(child => route.path === child.path)
}
function isGroupExpanded(item: NavItem): boolean {
return expandedGroups.value.has(item.path) || isGroupActive(item)
}
function toggleGroup(item: NavItem) {
if (expandedGroups.value.has(item.path)) {
expandedGroups.value.delete(item.path)
} else {
expandedGroups.value.add(item.path)
}
}
// Initialize theme // Initialize theme
const savedTheme = localStorage.getItem('theme') const savedTheme = localStorage.getItem('theme')
if ( if (
...@@ -752,6 +895,13 @@ onMounted(() => { ...@@ -752,6 +895,13 @@ onMounted(() => {
max-width: 12rem; max-width: 12rem;
} }
.sidebar-label-flex {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.sidebar-label-collapsed { .sidebar-label-collapsed {
max-width: 0; max-width: 0;
opacity: 0; opacity: 0;
...@@ -759,11 +909,14 @@ onMounted(() => { ...@@ -759,11 +909,14 @@ onMounted(() => {
pointer-events: none; pointer-events: none;
} }
/* Custom SVG icon in sidebar: inherit color, constrain size */ /* Custom SVG icon in sidebar: constrain size without overriding uploaded SVG colors */
.sidebar-svg-icon {
color: currentColor;
}
.sidebar-svg-icon :deep(svg) { .sidebar-svg-icon :deep(svg) {
display: block;
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
stroke: currentColor;
fill: none;
} }
</style> </style>
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const componentPath = resolve(dirname(fileURLToPath(import.meta.url)), '../AppSidebar.vue')
const componentSource = readFileSync(componentPath, 'utf8')
describe('AppSidebar custom SVG styles', () => {
it('does not override uploaded SVG fill or stroke colors', () => {
expect(componentSource).toContain('.sidebar-svg-icon {')
expect(componentSource).toContain('color: currentColor;')
expect(componentSource).toContain('display: block;')
expect(componentSource).not.toContain('stroke: currentColor;')
expect(componentSource).not.toContain('fill: none;')
})
})
<template>
<div class="space-y-4">
<!-- Quick Amount Buttons -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('payment.quickAmounts') }}
</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="amt in filteredAmounts"
:key="amt"
type="button"
:class="[
'rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors',
modelValue === amt
? 'border-primary-500 bg-primary-50 text-primary-700 dark:border-primary-400 dark:bg-primary-900/40 dark:text-primary-300'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
]"
@click="selectAmount(amt)"
>
{{ amt }}
</button>
</div>
</div>
<!-- Custom Amount Input -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('payment.customAmount') }}
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500">
$
</span>
<input
type="text"
inputmode="decimal"
:value="customText"
:placeholder="placeholderText"
class="input w-full py-3 pl-8 pr-4"
@input="handleInput"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(defineProps<{
amounts?: number[]
modelValue: number | null
min?: number
max?: number
}>(), {
amounts: () => [10, 20, 50, 100, 200, 500, 1000, 2000, 5000],
min: 0,
max: 0,
})
const emit = defineEmits<{
'update:modelValue': [value: number | null]
}>()
const { t } = useI18n()
const customText = ref('')
// 0 = no limit
const filteredAmounts = computed(() =>
props.amounts.filter((a) => (props.min <= 0 || a >= props.min) && (props.max <= 0 || a <= props.max))
)
const placeholderText = computed(() => {
if (props.min > 0 && props.max > 0) return `${props.min} - ${props.max}`
if (props.min > 0) return `≥ ${props.min}`
if (props.max > 0) return `≤ ${props.max}`
return t('payment.enterAmount')
})
const AMOUNT_PATTERN = /^\d*(\.\d{0,2})?$/
function selectAmount(amt: number) {
customText.value = String(amt)
emit('update:modelValue', amt)
}
function handleInput(e: Event) {
const val = (e.target as HTMLInputElement).value
if (!AMOUNT_PATTERN.test(val)) return
customText.value = val
if (val === '') {
emit('update:modelValue', null)
return
}
const num = parseFloat(val)
if (!isNaN(num) && num > 0) {
emit('update:modelValue', num)
} else {
emit('update:modelValue', null)
}
}
watch(() => props.modelValue, (v) => {
if (v !== null && String(v) !== customText.value) {
customText.value = String(v)
}
}, { immediate: true })
</script>
<template>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="statusClass"
>
{{ statusLabel }}
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { OrderStatus } from '@/types/payment'
const props = defineProps<{
status: OrderStatus
}>()
const { t } = useI18n()
const statusMap: Record<OrderStatus, { key: string; class: string }> = {
PENDING: { key: 'payment.status.pending', class: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400' },
PAID: { key: 'payment.status.paid', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
RECHARGING: { key: 'payment.status.recharging', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
COMPLETED: { key: 'payment.status.completed', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
EXPIRED: { key: 'payment.status.expired', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
CANCELLED: { key: 'payment.status.cancelled', class: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400' },
FAILED: { key: 'payment.status.failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
REFUND_REQUESTED: { key: 'payment.status.refund_requested', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
REFUNDING: { key: 'payment.status.refunding', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400' },
REFUNDED: { key: 'payment.status.refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
PARTIALLY_REFUNDED: { key: 'payment.status.partially_refunded', class: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400' },
REFUND_FAILED: { key: 'payment.status.refund_failed', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
}
const statusLabel = computed(() => {
const entry = statusMap[props.status]
return entry ? t(entry.key) : props.status
})
const statusClass = computed(() => {
const entry = statusMap[props.status]
return entry?.class ?? 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400'
})
</script>
<template>
<DataTable :columns="columns" :data="orders" :loading="loading">
<template #cell-id="{ value }">
<span class="font-mono text-sm">#{{ value }}</span>
</template>
<template #cell-out_trade_no="{ value }">
<span class="text-sm text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template v-if="showUser" #cell-user_email="{ value, row }">
<div class="text-sm">
<span class="text-gray-900 dark:text-white">{{ value || row.user_name || '#' + row.user_id }}</span>
<span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span>
</div>
</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">(${{ 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 }">
<OrderStatusBadge :status="value" />
</template>
<template #cell-created_at="{ value }">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatDate(value) }}</span>
</template>
<template #cell-actions="{ row }">
<slot name="actions" :row="row" />
</template>
</DataTable>
</template>
<script setup lang="ts">
import { 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 OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
const { t } = useI18n()
const props = defineProps<{
orders: PaymentOrder[]
loading: boolean
showUser?: boolean
}>()
function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString() }
const columns = computed((): Column[] => {
const cols: Column[] = [
{ key: 'id', label: t('payment.orders.orderId') },
{ key: 'out_trade_no', label: t('payment.orders.orderNo') },
]
if (props.showUser) {
cols.push({ key: 'user_email', label: t('payment.admin.colUser') })
}
cols.push(
{ key: 'amount', label: t('payment.orders.amount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') },
{ key: 'created_at', label: t('payment.orders.createdAt') },
{ key: 'actions', label: t('common.actions') },
)
return cols
})
</script>
<template>
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('payment.paymentMethod') }}
</label>
<div class="grid grid-cols-2 gap-3 sm:flex">
<button
v-for="method in sortedMethods"
:key="method.type"
type="button"
:disabled="!method.available"
:class="[
'relative flex h-[60px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
!method.available
? 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50'
: selected === method.type
? methodSelectedClass(method.type)
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-200 dark:hover:border-dark-500',
]"
@click="method.available && emit('select', method.type)"
>
<span class="flex items-center gap-2">
<img :src="methodIcon(method.type)" :alt="t(`payment.methods.${method.type}`)" class="h-7 w-7" />
<span class="flex flex-col items-start leading-none">
<span class="text-base font-semibold">{{ t(`payment.methods.${method.type}`) }}</span>
<span
v-if="method.fee_rate > 0"
class="text-[10px] tracking-wide text-gray-500 dark:text-dark-400"
>
{{ t('payment.fee') }} {{ method.fee_rate }}%
</span>
</span>
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { METHOD_ORDER } from './providerConfig'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
import stripeIcon from '@/assets/icons/stripe.svg'
export interface PaymentMethodOption {
type: string
fee_rate: number
available: boolean
}
const props = defineProps<{
methods: PaymentMethodOption[]
selected: string
}>()
const emit = defineEmits<{
select: [type: string]
}>()
const { t } = useI18n()
const METHOD_ICONS: Record<string, string> = {
alipay: alipayIcon,
wxpay: wxpayIcon,
stripe: stripeIcon,
}
const sortedMethods = computed(() => {
const order: readonly string[] = METHOD_ORDER
return [...props.methods].sort((a, b) => {
const ai = order.indexOf(a.type)
const bi = order.indexOf(b.type)
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi)
})
})
function methodIcon(type: string): string {
if (type.includes('alipay')) return METHOD_ICONS.alipay
if (type.includes('wxpay')) return METHOD_ICONS.wxpay
return METHOD_ICONS[type] || alipayIcon
}
function methodSelectedClass(type: string): string {
if (type.includes('alipay')) return 'border-[#02A9F1] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('wxpay')) return 'border-[#09BB07] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#676BE5] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
return 'border-primary-500 bg-primary-50 text-gray-900 shadow-sm dark:bg-primary-950 dark:text-gray-100'
}
</script>
<template>
<BaseDialog
:show="show"
:title="editing ? t('admin.settings.payment.editProvider') : t('admin.settings.payment.createProvider')"
width="wide"
@close="emit('close')"
>
<form id="provider-form" @submit.prevent="handleSave" class="space-y-4">
<!-- Name + Key -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">
{{ t('admin.settings.payment.providerName') }}
<span class="text-red-500">*</span>
</label>
<input v-model="form.name" type="text" class="input" required />
</div>
<div>
<label class="input-label">
{{ t('admin.settings.payment.providerKey') }}
<span class="text-red-500">*</span>
</label>
<Select
v-model="form.provider_key"
:options="(!!editing ? allKeyOptions : enabledKeyOptions) as SelectOption[]"
:disabled="!!editing"
@change="onKeyChange"
/>
</div>
</div>
<!-- Toggles + Payment mode + Supported types (single row) -->
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5">
<button
v-for="mode in paymentModeOptions"
:key="mode.value"
type="button"
@click="form.payment_mode = mode.value"
:class="[
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
form.payment_mode === mode.value
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>{{ mode.label }}</button>
</div>
</div>
<div v-if="availableTypes.length > 1" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.supportedTypes') }}</span>
<div class="flex flex-wrap gap-1.5">
<button
v-for="pt in availableTypes"
:key="pt.value"
type="button"
@click="toggleType(pt.value)"
:class="[
'rounded-lg border px-2.5 py-1 text-xs font-medium transition-all',
isTypeSelected(pt.value)
? 'border-primary-500 bg-primary-500 text-white shadow-sm'
: 'border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300 dark:hover:border-dark-500',
]"
>{{ pt.label }}</button>
</div>
</div>
</div>
<!-- Config fields -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-700">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.providerConfig') }}
</h4>
<div class="space-y-3">
<div v-for="field in resolvedFields" :key="field.key">
<label class="input-label">
{{ field.label }}
<span v-if="field.optional" class="text-xs text-gray-400">({{ t('common.optional') }})</span>
<span v-else class="text-red-500"> *</span>
</label>
<textarea
v-if="field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'"
v-model="config[field.key]"
rows="3"
class="input font-mono text-xs"
/>
<div v-else-if="field.sensitive" class="relative">
<input
:type="visibleFields[field.key] ? 'text' : 'password'"
v-model="config[field.key]"
class="input pr-10"
:placeholder="field.defaultValue || ''"
/>
<button
type="button"
@click="visibleFields[field.key] = !visibleFields[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg v-if="visibleFields[field.key]" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</button>
</div>
<input
v-else
type="text"
v-model="config[field.key]"
class="input"
:placeholder="field.defaultValue || ''"
/>
</div>
</div>
<!-- Callback URLs (each = editable URL + fixed path) -->
<div v-if="callbackPaths" class="mt-4 space-y-3">
<div v-if="callbackPaths.notifyUrl">
<label class="input-label">{{ t('admin.settings.payment.field_notifyUrl') }} <span class="text-red-500">*</span></label>
<div class="flex">
<input v-model="notifyBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.notifyUrl }}</span>
</div>
</div>
<div v-if="callbackPaths.returnUrl">
<label class="input-label">{{ t('admin.settings.payment.field_returnUrl') }} <span class="text-red-500">*</span></label>
<div class="flex">
<input v-model="returnBaseUrl" type="text" class="input min-w-0 flex-1 !rounded-r-none !border-r-0" :placeholder="defaultBaseUrl" />
<span class="inline-flex items-center whitespace-nowrap rounded-r-lg border border-gray-300 bg-gray-50 px-3 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400">{{ callbackPaths.returnUrl }}</span>
</div>
</div>
</div>
<!-- Stripe webhook hint -->
<div v-if="stripeWebhookUrl" class="mt-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800/50 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ t('admin.settings.payment.stripeWebhookHint') }}
</p>
<code class="mt-1 block break-all rounded bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900/40 dark:text-blue-200">
{{ stripeWebhookUrl }}
</code>
</div>
</div>
<!-- Per-type limits (collapsible) -->
<div v-if="limitableTypes.length" class="border-t border-gray-200 pt-4 dark:border-dark-700">
<button type="button" @click="limitsExpanded = !limitsExpanded" class="flex w-full items-center justify-between">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.limitsTitle') }}
</h4>
<svg :class="['h-4 w-4 text-gray-400 transition-transform', limitsExpanded && 'rotate-180']" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</button>
<div v-show="limitsExpanded" class="mt-3 space-y-3">
<div
v-for="lt in limitableTypes"
:key="lt.value"
class="rounded-lg border border-gray-100 p-3 dark:border-dark-700"
>
<p class="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">{{ lt.label }}</p>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMin') }}</label>
<input
type="number"
:value="getLimitVal(lt.value, 'singleMin')"
@input="setLimitVal(lt.value, 'singleMin', ($event.target as HTMLInputElement).value)"
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitSingleMax') }}</label>
<input
type="number"
:value="getLimitVal(lt.value, 'singleMax')"
@input="setLimitVal(lt.value, 'singleMax', ($event.target as HTMLInputElement).value)"
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.limitDaily') }}</label>
<input
type="number"
:value="getLimitVal(lt.value, 'dailyLimit')"
@input="setLimitVal(lt.value, 'dailyLimit', ($event.target as HTMLInputElement).value)"
class="input mt-0.5" min="1" step="0.01" :placeholder="limitPlaceholder(lt.value)"
/>
</div>
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">{{ t('admin.settings.payment.limitsHint') }}</p>
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" @click="emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="provider-form" :disabled="saving" class="btn btn-primary">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import {
PROVIDER_CONFIG_FIELDS,
PROVIDER_SUPPORTED_TYPES,
PROVIDER_CALLBACK_PATHS,
WEBHOOK_PATHS,
PAYMENT_MODE_QRCODE,
PAYMENT_MODE_POPUP,
getAvailableTypes,
extractBaseUrl,
} from './providerConfig'
const props = defineProps<{
show: boolean
saving: boolean
editing: ProviderInstance | null
allKeyOptions: TypeOption[]
enabledKeyOptions: TypeOption[]
allPaymentTypes: TypeOption[]
redirectLabel: string
}>()
const emit = defineEmits<{
close: []
save: [payload: {
provider_key: string
name: string
supported_types: string[]
enabled: boolean
payment_mode: string
refund_enabled: boolean
config: Record<string, string>
limits: string
}]
}>()
const { t } = useI18n()
// --- Form state ---
const form = reactive({
name: '',
provider_key: 'easypay',
supported_types: [] as string[],
enabled: true,
payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false,
})
const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({})
const notifyBaseUrl = ref('')
const returnBaseUrl = ref('')
const limitsExpanded = ref(false)
const visibleFields = reactive<Record<string, boolean>>({})
// --- Computed ---
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const stripeWebhookUrl = computed(() =>
form.provider_key === 'stripe' ? defaultBaseUrl + WEBHOOK_PATHS.stripe : '',
)
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
const paymentModeOptions = computed(() => {
return [
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
]
})
const availableTypes = computed(() => {
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
return base.map(opt =>
opt.label === opt.value
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
: opt,
)
})
const resolvedFields = computed(() => {
const fields = PROVIDER_CONFIG_FIELDS[form.provider_key] || []
return fields.map(f => ({
...f,
label: f.label || t(`admin.settings.payment.field_${f.key}`),
}))
})
const limitableTypes = computed(() => {
// Stripe: single "stripe" entry (one set of shared limits)
if (form.provider_key === 'stripe') {
return [{ value: 'stripe', label: 'Stripe' }]
}
const selected = form.supported_types.filter(t => t !== 'easypay')
return selected.map(v => {
const found = props.allPaymentTypes.find(pt => pt.value === v)
return found || { value: v, label: v }
})
})
// --- Methods ---
function isTypeSelected(type: string): boolean {
return form.supported_types.includes(type)
}
function toggleType(type: string) {
if (form.supported_types.includes(type)) {
form.supported_types = form.supported_types.filter(t => t !== type)
} else {
form.supported_types = [...form.supported_types, type]
}
}
function onKeyChange() {
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
clearConfig()
applyDefaults()
}
function clearConfig() {
Object.keys(config).forEach(k => delete config[k])
Object.keys(limits).forEach(k => delete limits[k])
Object.keys(visibleFields).forEach(k => delete visibleFields[k])
notifyBaseUrl.value = ''
returnBaseUrl.value = ''
limitsExpanded.value = false
}
function applyDefaults() {
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
if (f.defaultValue && !config[f.key]) config[f.key] = f.defaultValue
}
}
function getLimitVal(paymentType: string, field: string): string {
const val = limits[paymentType]?.[field]
return val && val > 0 ? String(val) : ''
}
/** Returns true if any limit field for this payment type has a value */
function hasAnyLimit(paymentType: string): boolean {
const l = limits[paymentType]
if (!l) return false
return (l.singleMin > 0) || (l.singleMax > 0) || (l.dailyLimit > 0)
}
/** Dynamic placeholder: "不限制" if sibling has value, "使用全局配置" if all empty */
function limitPlaceholder(paymentType: string): string {
return hasAnyLimit(paymentType)
? t('admin.settings.payment.limitsNoLimit')
: t('admin.settings.payment.limitsUseGlobal')
}
function setLimitVal(paymentType: string, field: string, val: string) {
if (!limits[paymentType]) limits[paymentType] = {}
const num = Number(val)
// Empty → clear the field (use global); reject ≤0
if (val === '' || isNaN(num)) {
delete limits[paymentType][field]
return
}
if (num <= 0) return
limits[paymentType][field] = num
}
function serializeLimits(): string {
const result: Record<string, Record<string, number>> = {}
for (const [pt, fields] of Object.entries(limits)) {
const clean: Record<string, number> = {}
for (const [k, v] of Object.entries(fields)) {
if (v > 0) clean[k] = v
}
if (Object.keys(clean).length > 0) result[pt] = clean
}
return Object.keys(result).length > 0 ? JSON.stringify(result) : ''
}
function handleSave() {
// Validate required fields
if (!form.name.trim()) {
emitValidationError(t('admin.settings.payment.validationNameRequired'))
return
}
// Validate required config fields — all non-optional fields must be filled
for (const f of PROVIDER_CONFIG_FIELDS[form.provider_key] || []) {
if (f.optional) continue
const val = (config[f.key] || '').trim()
if (!val) {
const label = f.label || t(`admin.settings.payment.field_${f.key}`)
emitValidationError(t('admin.settings.payment.validationFieldRequired', { field: label }))
return
}
}
const filteredConfig: Record<string, string> = {}
for (const [k, v] of Object.entries(config)) {
if (!v || !v.trim()) continue
// Skip masked values — backend keeps existing credentials
if (v === '••••••••') continue
filteredConfig[k] = v
}
// Inject computed callback URLs (each URL = independent base + fixed path)
// If base URL is empty, auto-fill with current domain
const paths = PROVIDER_CALLBACK_PATHS[form.provider_key]
if (paths) {
const notifyBase = notifyBaseUrl.value.trim() || defaultBaseUrl
const returnBase = returnBaseUrl.value.trim() || defaultBaseUrl
notifyBaseUrl.value = notifyBase
returnBaseUrl.value = returnBase
if (paths.notifyUrl) filteredConfig['notifyUrl'] = notifyBase + paths.notifyUrl
if (paths.returnUrl) filteredConfig['returnUrl'] = returnBase + paths.returnUrl
}
emit('save', {
provider_key: form.provider_key,
name: form.name,
supported_types: form.supported_types,
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
config: filteredConfig,
limits: serializeLimits(),
})
}
function emitValidationError(msg: string) {
// Use a custom event or inject appStore — for now use window alert fallback
// The parent handles this via the save event validation
import('@/stores').then(m => m.useAppStore().showError(msg))
}
// --- Public API for parent to call ---
function reset(defaultKey: string) {
form.name = ''
form.provider_key = defaultKey
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.refund_enabled = false
clearConfig()
applyDefaults()
}
function loadProvider(provider: ProviderInstance) {
form.name = provider.name
form.provider_key = provider.provider_key
form.supported_types = provider.supported_types
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
form.refund_enabled = provider.refund_enabled
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if (provider.config) {
for (const [k, v] of Object.entries(provider.config)) {
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
if (k === 'notifyUrl' || k === 'returnUrl') continue
config[k] = v
}
// Extract base URLs from existing callback URLs
const paths = PROVIDER_CALLBACK_PATHS[provider.provider_key]
if (paths?.notifyUrl && provider.config['notifyUrl']) {
notifyBaseUrl.value = extractBaseUrl(provider.config['notifyUrl'], paths.notifyUrl)
}
if (paths?.returnUrl && provider.config['returnUrl']) {
returnBaseUrl.value = extractBaseUrl(provider.config['returnUrl'], paths.returnUrl)
}
}
applyDefaults()
// Parse existing limits
if (provider.limits) {
try {
const parsed = JSON.parse(provider.limits)
for (const [pt, fields] of Object.entries(parsed as Record<string, Record<string, number>>)) {
limits[pt] = { ...fields }
}
limitsExpanded.value = Object.keys(limits).length > 0
} catch { /* ignore */ }
}
}
defineExpose({ reset, loadProvider })
</script>
<template>
<div class="card">
<!-- Header -->
<div class="border-b border-gray-100 px-4 py-3 dark:border-dark-700">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.payment.providerManagement') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.payment.providerManagementDesc') }}
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
@click="emit('refresh')"
:disabled="loading"
class="btn btn-secondary btn-sm"
:title="t('common.refresh')"
>
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
</button>
<button
type="button"
@click="emit('create')"
:disabled="!canCreate"
:class="canCreate
? 'btn btn-primary btn-sm'
: 'btn btn-secondary btn-sm cursor-not-allowed opacity-50'"
>
{{ t('admin.settings.payment.createProvider') }}
</button>
</div>
</div>
</div>
<!-- List -->
<div class="p-4">
<!-- Loading -->
<div v-if="loading && !providers.length" class="flex items-center justify-center py-6">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-primary-500 border-t-transparent" />
</div>
<!-- Provider cards (draggable) -->
<VueDraggable
v-if="providers.length"
v-model="localProviders"
:animation="200"
handle=".drag-handle"
class="space-y-3"
@end="onDragEnd"
>
<div v-for="p in localProviders" :key="p.id" class="flex items-start gap-2">
<div class="drag-handle mt-3 flex cursor-grab items-center text-gray-300 hover:text-gray-500 active:cursor-grabbing dark:text-dark-600 dark:hover:text-dark-400">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M7 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM7 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM13 14a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/>
</svg>
</div>
<div class="min-w-0 flex-1">
<ProviderCard
:provider="p"
:enabled="isEnabled(p.provider_key)"
:available-types="getTypes(p.provider_key)"
@toggle-field="(field) => emit('toggleField', p, field)"
@toggle-type="(type) => emit('toggleType', p, type)"
@edit="emit('edit', p)"
@delete="emit('delete', p)"
/>
</div>
</div>
</VueDraggable>
<!-- Empty -->
<div v-else-if="!loading" class="py-6 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ canCreate
? t('admin.settings.payment.noProviders')
: t('admin.settings.payment.enableTypesFirst') }}
</p>
<button
type="button"
v-if="canCreate"
@click="emit('create')"
class="btn btn-primary btn-sm mt-2"
>
{{ t('admin.settings.payment.createProvider') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueDraggable } from 'vue-draggable-plus'
import Icon from '@/components/icons/Icon.vue'
import ProviderCard from './ProviderCard.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import { getAvailableTypes } from './providerConfig'
const props = defineProps<{
providers: ProviderInstance[]
loading: boolean
canCreate: boolean
enabledPaymentTypes: string[]
allPaymentTypes: TypeOption[]
redirectLabel: string
}>()
const emit = defineEmits<{
refresh: []
create: []
edit: [provider: ProviderInstance]
delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]]
}>()
const { t } = useI18n()
const localProviders = ref<ProviderInstance[]>([])
watch(() => props.providers, (val) => {
localProviders.value = [...val]
}, { immediate: true })
function onDragEnd() {
const updates = localProviders.value.map((p, idx) => ({
id: p.id,
sort_order: idx,
}))
emit('reorder', updates)
}
function isEnabled(providerKey: string): boolean {
return props.enabledPaymentTypes.includes(providerKey)
}
function getTypes(providerKey: string): TypeOption[] {
return getAvailableTypes(providerKey, props.allPaymentTypes, props.redirectLabel)
.map(opt => opt.label === opt.value
? { ...opt, label: t(`payment.methods.${opt.value}`, opt.value) }
: opt,
)
}
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment