"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
<template>
<BaseDialog :show="show" :title="dialogTitle" width="narrow" @close="handleClose">
<!-- QR Code + Polling State -->
<div v-if="!success" class="flex flex-col items-center space-y-4">
<!-- QR Code mode -->
<template v-if="qrUrl">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
</div>
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ scanHint }}
</p>
</template>
<!-- Popup window waiting mode (no QR code) -->
<template v-else>
<div class="flex flex-col items-center py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
<button v-if="payUrl" class="btn btn-secondary mt-3 text-sm" @click="reopenPopup">
{{ t('payment.qr.openPayWindow') }}
</button>
</div>
</template>
<!-- Countdown -->
<div v-if="expired" class="text-center">
<p class="text-lg font-medium text-red-500">{{ t('payment.qr.expired') }}</p>
</div>
<div v-else class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ qrUrl ? t('payment.qr.expiresIn') : '' }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
</div>
<!-- Success State -->
<div v-else class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button v-if="!success && !expired" class="btn btn-secondary" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
<button v-if="success" class="btn btn-primary" @click="handleDone">
{{ t('common.confirm') }}
</button>
<button v-if="expired" class="btn btn-primary" @click="handleClose">
{{ t('payment.result.backToRecharge') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
const props = defineProps<{
show: boolean
orderId: number
qrCode: string
expiresAt: string
paymentType: string
/** URL for reopening the payment popup window */
payUrl?: string
}>()
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const paymentStore = usePaymentStore()
const appStore = useAppStore()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrUrl = ref('')
const remainingSeconds = ref(0)
const expired = ref(false)
const cancelling = ref(false)
const success = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
if (!qrUrl.value) return t('payment.qr.payInNewWindow')
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
return t('payment.qr.scanToPay')
})
const scanHint = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
return ''
})
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
function getLogoForType(): string | null {
if (isAlipay.value) return alipayIcon
if (isWxpay.value) return wxpayIcon
return null
}
function reopenPopup() {
if (props.payUrl) {
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
}
}
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
const logoSrc = getLogoForType()
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220,
margin: 2,
errorCorrectionLevel: logoSrc ? 'H' : 'M',
})
if (!logoSrc) return
const canvas = qrCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.src = logoSrc
img.onload = () => {
const logoSize = 40
const x = (canvas.width - logoSize) / 2
const y = (canvas.height - logoSize) / 2
const pad = 4
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
const r = 5
ctx.moveTo(x - pad + r, y - pad)
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
ctx.fill()
ctx.drawImage(img, x, y, logoSize, logoSize)
}
}
async function pollStatus() {
if (!props.orderId) return
const order = await paymentStore.pollOrderStatus(props.orderId)
if (!order) return
if (order.status === 'COMPLETED' || order.status === 'PAID') {
cleanup()
paidOrder.value = order
success.value = true
emit('success')
} else if (order.status === 'EXPIRED' || order.status === 'CANCELLED' || order.status === 'FAILED') {
cleanup()
expired.value = true
}
}
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
if (remainingSeconds.value <= 0) {
expired.value = true
return
}
countdownTimer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
expired.value = true
cleanup()
}
}, 1000)
}
async function handleCancel() {
if (!props.orderId || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(props.orderId)
cleanup()
emit('close')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
function handleClose() {
cleanup()
emit('close')
}
function handleDone() {
cleanup()
emit('close')
}
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
function init() {
// Reset state
success.value = false
paidOrder.value = null
expired.value = false
cancelling.value = false
qrUrl.value = props.qrCode
let seconds = 30 * 60
if (props.expiresAt) {
const expiresAt = new Date(props.expiresAt)
seconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
}
startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
}
// Watch for dialog open/close
watch(() => props.show, (isOpen) => {
if (isOpen) {
init()
} else {
cleanup()
}
})
watch(qrUrl, () => renderQR())
onUnmounted(() => cleanup())
</script>
<template>
<div class="space-y-4">
<!-- ═══ Terminal States: show result, user clicks to return ═══ -->
<!-- Success -->
<template v-if="outcome === 'success'">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ props.orderType === 'subscription' ? t('payment.result.subscriptionSuccess') : t('payment.result.success') }}</p>
<div v-if="paidOrder" class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ paidOrder.id }}</span>
</div>
<div v-if="paidOrder.out_trade_no" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.out_trade_no }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
</div>
</div>
</template>
<!-- Cancelled -->
<template v-else-if="outcome === 'cancelled'">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.cancelled') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.cancelledDesc') }}</p>
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
</div>
</div>
</template>
<!-- Expired / Failed -->
<template v-else-if="outcome === 'expired'">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/30">
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.qr.expired') }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiredDesc') }}</p>
<button class="btn btn-primary" @click="handleDone">{{ t('common.confirm') }}</button>
</div>
</div>
</template>
<!-- ═══ Active States: QR or Popup waiting ═══ -->
<!-- QR Code Mode -->
<template v-else-if="qrUrl">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
<!-- Brand logo overlay -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
</div>
</div>
<div class="card p-4 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
</template>
<!-- Waiting for Popup/Redirect Mode -->
<template v-else>
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
<button v-if="payUrl" class="btn btn-secondary text-sm" @click="reopenPopup">
{{ t('payment.qr.openPayWindow') }}
</button>
</div>
</div>
<div class="card p-4 text-center">
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError'
import { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
const props = defineProps<{
orderId: number
qrCode: string
expiresAt: string
paymentType: string
payUrl?: string
orderType?: string
}>()
const emit = defineEmits<{ done: []; success: [] }>()
const { t } = useI18n()
const paymentStore = usePaymentStore()
const appStore = useAppStore()
const qrCanvas = ref<HTMLCanvasElement | null>(null)
const qrUrl = ref('')
const remainingSeconds = ref(0)
const cancelling = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
})
const qrLogoBgClass = computed(() => {
if (isAlipay.value) return 'bg-[#00AEEF]'
if (isWxpay.value) return 'bg-[#2BB741]'
return 'bg-gray-400'
})
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
return t('payment.qr.scanToPay')
})
const scanHint = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipayHint')
if (isWxpay.value) return t('payment.qr.scanWxpayHint')
return ''
})
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
function reopenPopup() {
if (props.payUrl) {
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
}
}
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220, margin: 2,
errorCorrectionLevel: 'H',
})
}
async function pollStatus() {
if (!props.orderId || outcome.value) return
const order = await paymentStore.pollOrderStatus(props.orderId)
if (!order) return
if (order.status === 'COMPLETED' || order.status === 'PAID') {
cleanup()
paidOrder.value = order
outcome.value = 'success'
emit('success')
} else if (order.status === 'CANCELLED') {
cleanup()
outcome.value = 'cancelled'
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
cleanup()
outcome.value = 'expired'
}
}
function startCountdown(seconds: number) {
remainingSeconds.value = Math.max(0, seconds)
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
countdownTimer = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
}, 1000)
}
async function handleCancel() {
if (!props.orderId || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(props.orderId)
cleanup()
outcome.value = 'cancelled'
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
function handleDone() { cleanup(); emit('done') }
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
// Initialize on mount
qrUrl.value = props.qrCode
let seconds = 30 * 60
if (props.expiresAt) {
seconds = Math.floor((new Date(props.expiresAt).getTime() - Date.now()) / 1000)
}
startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
watch(() => qrUrl.value, () => renderQR())
onUnmounted(() => cleanup())
</script>
<template>
<div
:class="[
'group relative rounded-lg border transition-all',
enabled ? 'border-gray-200 dark:border-dark-600' : 'border-gray-200 bg-gray-50 opacity-50 dark:border-dark-700 dark:bg-dark-800/50',
]"
:title="!enabled ? t('admin.settings.payment.typeDisabled') + ' — ' + t('admin.settings.payment.enableTypesFirst') : undefined"
>
<div :class="[
'flex items-center justify-between px-4 py-2.5',
!enabled && 'pointer-events-none',
]">
<!-- Left: icon + name + key badge + type badges -->
<div class="flex items-center gap-3">
<div :class="[
'rounded-md p-1.5',
provider.enabled && enabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-gray-100 dark:bg-dark-700',
]">
<Icon
name="server"
size="sm"
:class="provider.enabled && enabled ? 'text-green-600 dark:text-green-400' : 'text-gray-400'"
/>
</div>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ provider.name }}</span>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ keyLabel }}</span>
<span v-if="provider.payment_mode" class="text-xs text-gray-400 dark:text-gray-500">· {{ modeLabel }}</span>
<span v-if="enabled && availableTypes.length" class="text-xs text-gray-300 dark:text-gray-600">|</span>
<div v-if="enabled" class="flex items-center gap-1">
<button
v-for="pt in availableTypes"
:key="pt.value"
type="button"
@click="emit('toggleType', pt.value)"
:class="[
'rounded px-2 py-0.5 text-xs font-medium transition-all',
isSelected(pt.value)
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-400 dark:bg-dark-700 dark:text-gray-500',
]"
>{{ pt.label }}</button>
</div>
</div>
<!-- Right: toggles + actions -->
<div class="flex items-center gap-4">
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
<button type="button" @click="emit('edit')" 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 type="button" @click="emit('delete')" 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>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import { PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
const PROVIDER_KEY_LABELS: Record<string, string> = {
easypay: 'admin.settings.payment.providerEasypay',
alipay: 'admin.settings.payment.providerAlipay',
wxpay: 'admin.settings.payment.providerWxpay',
stripe: 'admin.settings.payment.providerStripe',
}
const props = defineProps<{
provider: ProviderInstance
enabled: boolean
availableTypes: TypeOption[]
}>()
const emit = defineEmits<{
toggleField: [field: 'enabled' | 'refund_enabled']
toggleType: [type: string]
edit: []
delete: []
}>()
const { t } = useI18n()
const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_key] || props.provider.provider_key))
const modeLabel = computed(() => {
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
return ''
})
function isSelected(type: string): boolean {
return props.provider.supported_types.includes(type)
}
</script>
<template>
<div class="space-y-4">
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
</div>
<div v-else-if="initError" class="card p-6 text-center">
<p class="text-sm text-red-600 dark:text-red-400">{{ initError }}</p>
<button class="btn btn-secondary mt-4" @click="$emit('back')">{{ t('payment.result.backToRecharge') }}</button>
</div>
<!-- Success -->
<template v-else-if="success">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<Icon name="check" size="lg" class="text-green-500" />
</div>
<p class="text-lg font-bold text-gray-900 dark:text-white">{{ t('payment.result.success') }}</p>
<div class="w-full rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ orderId }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ payAmount.toFixed(2) }}</span>
</div>
</div>
</div>
<button class="btn btn-primary" @click="$emit('done')">{{ t('common.confirm') }}</button>
</div>
</div>
</template>
<template v-else>
<!-- Amount -->
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">${{ payAmount.toFixed(2) }}</p>
</div>
</div>
<!-- Stripe Payment Element -->
<div class="card p-6">
<div ref="stripeMount" class="min-h-[200px]"></div>
<p v-if="error" class="mt-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<button class="btn btn-stripe mt-6 w-full py-3 text-base" :disabled="submitting || !ready" @click="handlePay">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
</span>
<span v-else>{{ t('payment.stripePay') }}</span>
</button>
</div>
<!-- Cancel order -->
<button class="btn btn-secondary w-full" :disabled="cancelling" @click="handleCancel">
{{ cancelling ? t('common.processing') : t('payment.qr.cancelOrder') }}
</button>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { extractApiErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores'
import { STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import Icon from '@/components/icons/Icon.vue'
// Stripe payment methods that open a popup (redirect or QR code)
const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const props = defineProps<{
orderId: number
clientSecret: string
publishableKey: string
payAmount: number
}>()
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
const { t } = useI18n()
const router = useRouter()
const appStore = useAppStore()
const stripeMount = ref<HTMLElement | null>(null)
const loading = ref(true)
const initError = ref('')
const error = ref('')
const submitting = ref(false)
const cancelling = ref(false)
const success = ref(false)
const ready = ref(false)
const selectedType = ref('')
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
onMounted(async () => {
try {
const { loadStripe } = await import('@stripe/stripe-js')
const stripe = await loadStripe(props.publishableKey)
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
stripeInstance = stripe
loading.value = false
await nextTick()
if (!stripeMount.value) return
const isDark = document.documentElement.classList.contains('dark')
const elements = stripe.elements({
clientSecret: props.clientSecret,
appearance: { theme: isDark ? 'night' : 'stripe', variables: { borderRadius: '8px' } },
})
elementsInstance = elements
const paymentElement = elements.create('payment', {
layout: 'tabs',
paymentMethodOrder: ['alipay', 'wechat_pay', 'card', 'link'],
} as Record<string, unknown>)
paymentElement.mount(stripeMount.value)
paymentElement.on('ready', () => { ready.value = true })
paymentElement.on('change', (event: { value: { type: string } }) => {
selectedType.value = event.value.type
})
} catch (err: unknown) {
initError.value = extractApiErrorMessage(err, t('payment.stripeLoadFailed'))
} finally {
loading.value = false
}
})
async function handlePay() {
if (!stripeInstance || !elementsInstance || submitting.value) return
// Alipay / WeChat Pay: open popup for redirect or QR display
if (POPUP_METHODS.has(selectedType.value)) {
const popupUrl = router.resolve({
path: '/payment/stripe-popup',
query: {
order_id: String(props.orderId),
method: selectedType.value,
amount: String(props.payAmount),
},
}).href
const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES)
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
window.removeEventListener('message', onReady)
popup?.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret: props.clientSecret,
publishableKey: props.publishableKey,
}, window.location.origin)
}
window.addEventListener('message', onReady)
emit('redirect', props.orderId, popupUrl)
return
}
// Card / Link: confirm inline
submitting.value = true
error.value = ''
try {
const { error: stripeError } = await stripeInstance.confirmPayment({
elements: elementsInstance,
confirmParams: {
return_url: window.location.origin + '/payment/result?order_id=' + props.orderId + '&status=success',
},
redirect: 'if_required',
})
if (stripeError) {
error.value = stripeError.message || t('payment.result.failed')
} else {
success.value = true
emit('success')
}
} catch (err: unknown) {
error.value = extractApiErrorMessage(err, t('payment.result.failed'))
} finally {
submitting.value = false
}
}
async function handleCancel() {
if (!props.orderId || cancelling.value) return
cancelling.value = true
try {
await paymentAPI.cancelOrder(props.orderId)
emit('back')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
cancelling.value = false
}
}
</script>
<template>
<div
:class="[
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
'hover:shadow-xl hover:-translate-y-0.5',
borderClass,
'bg-white dark:bg-dark-800',
]"
>
<!-- Colored top accent bar -->
<div :class="['h-1.5', accentClass]" />
<div class="flex flex-1 flex-col p-4">
<!-- Header: name + badge + price -->
<div class="mb-3 flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
<span :class="['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]">
{{ pLabel }}
</span>
</div>
<p v-if="plan.description" class="mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2">
{{ plan.description }}
</p>
</div>
<div class="shrink-0 text-right">
<div class="flex items-baseline gap-1">
<span class="text-xs text-gray-400 dark:text-dark-500">$</span>
<span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span>
</div>
<span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
<div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5">
<span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span>
<span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span>
</div>
</div>
</div>
<!-- Group quota info (compact) -->
<div class="mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50">
<div class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span>
</div>
<div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span>
</div>
<div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span>
</div>
<div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span>
</div>
<div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span>
</div>
<div v-if="modelScopeLabels.length > 0" class="col-span-2 flex items-center justify-between">
<span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span>
<div class="flex flex-wrap justify-end gap-1">
<span v-for="scope in modelScopeLabels" :key="scope"
class="rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300">
{{ scope }}
</span>
</div>
</div>
</div>
<!-- Features list (compact) -->
<div v-if="plan.features.length > 0" class="mb-3 space-y-1">
<div v-for="feature in plan.features" :key="feature" class="flex items-start gap-1.5">
<svg :class="['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
<span class="text-xs text-gray-600 dark:text-gray-300">{{ feature }}</span>
</div>
</div>
<div class="flex-1" />
<!-- Subscribe Button -->
<button
type="button"
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@click="emit('select', plan)"
>
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import {
platformAccentBarClass,
platformBadgeLightClass,
platformBorderClass,
platformTextClass,
platformIconClass,
platformButtonClass,
platformDiscountClass,
platformLabel,
} from '@/utils/platformColors'
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
const { t } = useI18n()
const platform = computed(() => props.plan.group_platform || '')
const isRenewal = computed(() =>
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
)
// Derived color classes from central config
const accentClass = computed(() => platformAccentBarClass(platform.value))
const borderClass = computed(() => platformBorderClass(platform.value))
const badgeLightClass = computed(() => platformBadgeLightClass(platform.value))
const textClass = computed(() => platformTextClass(platform.value))
const iconClass = computed(() => platformIconClass(platform.value))
const btnClass = computed(() => platformButtonClass(platform.value))
const discountClass = computed(() => platformDiscountClass(platform.value))
const pLabel = computed(() => platformLabel(platform.value))
const discountText = computed(() => {
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
return pct > 0 ? `-${pct}%` : ''
})
const rateDisplay = computed(() => {
const rate = props.plan.rate_multiplier ?? 1
return ${Number(rate.toPrecision(10))}`
})
const MODEL_SCOPE_LABELS: Record<string, string> = {
claude: 'Claude',
gemini_text: 'Gemini',
gemini_image: 'Imagen',
}
const modelScopeLabels = computed(() => {
const scopes = props.plan.supported_model_scopes
if (!scopes || scopes.length === 0) return []
return scopes.map(s => MODEL_SCOPE_LABELS[s] || s)
})
const validitySuffix = computed(() => {
const u = props.plan.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${props.plan.validity_days}${t('payment.days')}`
})
</script>
<template>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</span>
<button
type="button"
role="switch"
:aria-checked="checked"
@click="emit('toggle')"
:class="[
'relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
checked ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600',
]"
>
<span :class="[
'pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200',
checked ? 'translate-x-4' : 'translate-x-0',
]" />
</button>
</label>
</template>
<script setup lang="ts">
defineProps<{ label: string; checked: boolean }>()
const emit = defineEmits<{ toggle: [] }>()
</script>
/**
* Shared utility functions for payment order display.
* Used by AdminOrderDetail, AdminOrderTable, AdminRefundDialog, AdminOrdersView, etc.
*/
const STATUS_BADGE_MAP: Record<string, string> = {
PENDING: 'badge-warning',
PAID: 'badge-info',
RECHARGING: 'badge-info',
COMPLETED: 'badge-success',
EXPIRED: 'badge-secondary',
CANCELLED: 'badge-secondary',
FAILED: 'badge-danger',
REFUND_REQUESTED: 'badge-warning',
REFUNDING: 'badge-warning',
PARTIALLY_REFUNDED: 'badge-warning',
REFUNDED: 'badge-info',
REFUND_FAILED: 'badge-danger',
}
const REFUNDABLE_STATUSES = ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED']
export function statusBadgeClass(status: string): string {
return STATUS_BADGE_MAP[status] || 'badge-secondary'
}
export function canRefund(status: string): boolean {
return REFUNDABLE_STATUSES.includes(status)
}
export function formatOrderDateTime(dateStr: string): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
/**
* Shared constants and types for payment provider management.
*/
// --- Types ---
export interface ConfigFieldDef {
key: string
label: string
sensitive: boolean
optional?: boolean
defaultValue?: string
}
export interface TypeOption {
value: string
label: string
}
/** Callback URL paths for a provider. */
export interface CallbackPaths {
notifyUrl?: string
returnUrl?: string
}
// --- Constants ---
/** Maps provider key → available payment types. */
export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
easypay: ['alipay', 'wxpay'],
alipay: ['alipay'],
wxpay: ['wxpay'],
stripe: ['card', 'alipay', 'wxpay', 'link'],
}
/** Available payment modes for EasyPay providers. */
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'popup'] as const
/** Fixed display order for user-facing payment methods */
export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', 'stripe'] as const
/** Payment mode constants */
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_POPUP = 'popup'
/** Window features for payment popup windows */
export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes'
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes'
/** Webhook paths for each provider (relative to origin). */
export const WEBHOOK_PATHS: Record<string, string> = {
easypay: '/api/v1/payment/webhook/easypay',
alipay: '/api/v1/payment/webhook/alipay',
wxpay: '/api/v1/payment/webhook/wxpay',
stripe: '/api/v1/payment/webhook/stripe',
}
export const RETURN_PATH = '/payment/result'
/** Fixed callback paths per provider — displayed as read-only after base URL. */
export const PROVIDER_CALLBACK_PATHS: Record<string, CallbackPaths> = {
easypay: { notifyUrl: WEBHOOK_PATHS.easypay, returnUrl: RETURN_PATH },
alipay: { notifyUrl: WEBHOOK_PATHS.alipay, returnUrl: RETURN_PATH },
wxpay: { notifyUrl: WEBHOOK_PATHS.wxpay },
// stripe: no callback URL config needed (webhook is separate)
}
/** Per-provider config fields (excludes notifyUrl/returnUrl which are handled separately). */
export const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
easypay: [
{ key: 'pid', label: 'PID', sensitive: false },
{ key: 'pkey', label: 'PKey', sensitive: true },
{ key: 'apiBase', label: '', sensitive: false },
{ key: 'cidAlipay', label: '', sensitive: false, optional: true },
{ key: 'cidWxpay', label: '', sensitive: false, optional: true },
],
alipay: [
{ key: 'appId', label: 'App ID', sensitive: false },
{ key: 'privateKey', label: '', sensitive: true },
{ key: 'publicKey', label: '', sensitive: true },
],
wxpay: [
{ key: 'appId', label: 'App ID', sensitive: false },
{ key: 'mchId', label: '', sensitive: false },
{ key: 'privateKey', label: '', sensitive: true },
{ key: 'apiV3Key', label: '', sensitive: true },
{ key: 'publicKey', label: '', sensitive: true },
{ key: 'publicKeyId', label: '', sensitive: false, optional: true },
{ key: 'certSerial', label: '', sensitive: false, optional: true },
],
stripe: [
{ key: 'secretKey', label: '', sensitive: true },
{ key: 'publishableKey', label: '', sensitive: false },
{ key: 'webhookSecret', label: '', sensitive: true },
],
}
// --- Helpers ---
/** Resolve type label for display. */
export function resolveTypeLabel(
typeVal: string,
_providerKey: string,
allTypes: TypeOption[],
_redirectLabel: string,
): TypeOption {
return allTypes.find(pt => pt.value === typeVal) || { value: typeVal, label: typeVal }
}
/** Get available type options for a provider key. */
export function getAvailableTypes(
providerKey: string,
allTypes: TypeOption[],
redirectLabel: string,
): TypeOption[] {
const types = PROVIDER_SUPPORTED_TYPES[providerKey] || []
return types.map(t => resolveTypeLabel(t, providerKey, allTypes, redirectLabel))
}
/** Extract base URL from a full callback URL by removing the known path suffix. */
export function extractBaseUrl(fullUrl: string, path: string): string {
if (!fullUrl) return ''
if (fullUrl.endsWith(path)) return fullUrl.slice(0, -path.length)
// Fallback: try to extract origin
try { return new URL(fullUrl).origin } catch { return fullUrl }
}
import { afterEach, describe, expect, it } from 'vitest'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
describe('usePersistedPageSize', () => {
afterEach(() => {
localStorage.clear()
delete window.__APP_CONFIG__
})
it('uses the system table default instead of stale localStorage state', () => {
window.__APP_CONFIG__ = {
table_default_page_size: 1000,
table_page_size_options: [20, 50, 1000]
} as any
localStorage.setItem('table-page-size', '50')
localStorage.setItem('table-page-size-source', 'user')
expect(getPersistedPageSize()).toBe(1000)
})
})
const STORAGE_KEY = 'table-page-size' import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences'
const DEFAULT_PAGE_SIZE = 20
/** /**
* 从 localStorage 读取/写入 pageSize * 读取当前系统配置的表格默认每页条数。
* 全局共享一个 key,所有表格统一偏好 * 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。
*/ */
export function getPersistedPageSize(fallback = DEFAULT_PAGE_SIZE): number { export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number {
try { return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback)
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = Number(stored)
if (Number.isFinite(parsed) && parsed > 0) return parsed
}
} catch {
// localStorage 不可用(隐私模式等)
}
return fallback
}
export function setPersistedPageSize(size: number): void {
try {
localStorage.setItem(STORAGE_KEY, String(size))
} catch {
// 静默失败
}
} }
import { ref, reactive, onUnmounted, toRaw } from 'vue' import { ref, reactive, onUnmounted, toRaw } from 'vue'
import { useDebounceFn } from '@vueuse/core' import { useDebounceFn } from '@vueuse/core'
import type { BasePaginationResponse, FetchOptions } from '@/types' import type { BasePaginationResponse, FetchOptions } from '@/types'
import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize' import { getPersistedPageSize } from './usePersistedPageSize'
interface PaginationState { interface PaginationState {
page: number page: number
...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL ...@@ -88,7 +88,6 @@ export function useTableLoader<T, P extends Record<string, any>>(options: TableL
const handlePageSizeChange = (size: number) => { const handlePageSizeChange = (size: number) => {
pagination.page_size = size pagination.page_size = size
pagination.page = 1 pagination.page = 1
setPersistedPageSize(size)
load() load()
} }
......
...@@ -308,6 +308,8 @@ export default { ...@@ -308,6 +308,8 @@ export default {
chooseFile: 'Choose File', chooseFile: 'Choose File',
notAvailable: 'N/A', notAvailable: 'N/A',
now: 'Now', now: 'Now',
today: 'Today',
tomorrow: 'Tomorrow',
unknown: 'Unknown', unknown: 'Unknown',
minutes: 'min', minutes: 'min',
time: { time: {
...@@ -353,7 +355,11 @@ export default { ...@@ -353,7 +355,11 @@ export default {
mySubscriptions: 'My Subscriptions', mySubscriptions: 'My Subscriptions',
buySubscription: 'Recharge / Subscription', buySubscription: 'Recharge / Subscription',
docs: 'Docs', docs: 'Docs',
sora: 'Sora Studio' myOrders: 'My Orders',
orderManagement: 'Orders',
paymentDashboard: 'Payment Dashboard',
paymentConfig: 'Payment Config',
paymentPlans: 'Plans'
}, },
// Auth // Auth
...@@ -2051,6 +2057,7 @@ export default { ...@@ -2051,6 +2057,7 @@ export default {
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
overloaded: 'Overloaded', overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable', tempUnschedulable: 'Temp Unschedulable',
unschedulable: 'Unschedulable',
rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}', rateLimitedUntil: 'Rate limited and removed from scheduling. Auto resumes at {time}',
rateLimitedAutoResume: 'Auto resumes in {time}', rateLimitedAutoResume: 'Auto resumes in {time}',
modelRateLimitedUntil: '{model} rate limited until {time}', modelRateLimitedUntil: '{model} rate limited until {time}',
...@@ -4181,7 +4188,7 @@ export default { ...@@ -4181,7 +4188,7 @@ export default {
gateway: 'Gateway', gateway: 'Gateway',
email: 'Email', email: 'Email',
backup: 'Backup', backup: 'Backup',
data: 'Sora Storage', payment: 'Payment',
}, },
emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledTitle: 'Email Verification Not Enabled',
emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.',
...@@ -4353,6 +4360,15 @@ export default { ...@@ -4353,6 +4360,15 @@ export default {
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
apiBaseUrlHint: apiBaseUrlHint:
'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.',
tablePreferencesTitle: 'Global Table Preferences',
tablePreferencesDescription: 'Configure default pagination behavior for shared table components',
tableDefaultPageSize: 'Default Rows Per Page',
tableDefaultPageSizeHint: 'Must be an integer between 5 and 1000',
tablePageSizeOptions: 'Rows Per Page Options',
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
tablePageSizeOptionsHint: 'Use commas to separate integers between 5 and 1000; values are deduplicated and sorted on save',
tableDefaultPageSizeRangeError: 'Default rows per page must be between {min} and {max}',
tablePageSizeOptionsFormatError: 'Invalid options format. Enter comma-separated integers between {min} and {max}',
customEndpoints: { customEndpoints: {
title: 'Custom Endpoints', title: 'Custom Endpoints',
description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page', description: 'Add additional API endpoint URLs for users to quickly copy on the API Keys page',
...@@ -4425,6 +4441,100 @@ export default { ...@@ -4425,6 +4441,100 @@ export default {
moveUp: 'Move Up', moveUp: 'Move Up',
moveDown: 'Move Down', moveDown: 'Move Down',
}, },
payment: {
title: 'Payment Settings',
description: 'Configure payment system options',
enabled: 'Enable Payment',
enabledHint: 'Enable or disable the payment system',
enabledPaymentTypes: 'Enabled Providers',
enabledPaymentTypesHint: 'Disabling a provider will also disable its instances',
minAmount: 'Minimum Amount',
maxAmount: 'Maximum Amount',
dailyLimit: 'Daily Limit',
orderTimeout: 'Order Timeout',
orderTimeoutHint: 'In minutes, minimum 1',
maxPendingOrders: 'Max Pending Orders',
cancelRateLimit: 'Limit Cancel Rate',
cancelRateLimitHint: 'When enabled, users who exceed the cancel limit within the time window cannot create new orders',
cancelRateLimitEvery: 'Every',
cancelRateLimitAllowMax: 'allow max',
cancelRateLimitTimes: 'cancels',
cancelRateLimitWindow: 'Window',
cancelRateLimitUnit: 'Unit',
cancelRateLimitMax: 'Max Cancels',
cancelRateLimitUnitMinute: 'Minutes',
cancelRateLimitUnitHour: 'Hours',
cancelRateLimitUnitDay: 'Days',
cancelRateLimitWindowMode: 'Window Mode',
cancelRateLimitWindowModeRolling: 'Rolling',
cancelRateLimitWindowModeFixed: 'Fixed',
helpText: 'Help Text',
helpImageUrl: 'Help Image URL',
manageProviders: 'Manage Providers',
balancePaymentDisabled: 'Disable Balance Recharge',
noLimit: 'Empty = no limit',
helpImage: 'Help Image',
helpImagePlaceholder: 'Upload or enter image URL',
helpTextPlaceholder: 'Enter help text...',
providerEasypay: 'EasyPay',
providerAlipay: 'Alipay (Direct)',
providerWxpay: 'WeChat Pay (Direct)',
providerStripe: 'Stripe',
typeDisabled: 'type disabled',
enableTypesFirst: 'Enable at least one payment type above first',
easypayRedirect: 'Redirect',
paymentMode: 'Payment Mode',
modeRedirect: 'Redirect',
modeQRCode: 'QR Code',
modePopup: 'Popup',
validationNameRequired: 'Provider name is required',
validationTypesRequired: 'Please select at least one supported payment type',
validationFieldRequired: '{field} is required',
field_apiBase: 'API Base URL',
field_notifyUrl: 'Notify URL',
field_returnUrl: 'Return URL',
callbackBaseUrl: 'Callback Base URL',
field_privateKey: 'Private Key',
field_publicKey: 'Public Key',
field_mchId: 'Merchant ID',
field_apiV3Key: 'API v3 Key',
field_publicKeyId: 'Public Key ID',
field_certSerial: 'Certificate Serial',
field_secretKey: 'Secret Key',
field_publishableKey: 'Publishable Key',
field_webhookSecret: 'Webhook Secret',
field_cid: 'Channel ID',
field_cidAlipay: 'Alipay Channel ID',
field_cidWxpay: 'WeChat Channel ID',
stripeWebhookHint: 'Configure the following URL as a Webhook endpoint in Stripe Dashboard:',
limitsTitle: 'Limits',
limitSingleMin: 'Min per order',
limitSingleMax: 'Max per order',
limitDaily: 'Daily limit',
limitsHint: 'All empty = use global config; partially filled = empty means no limit',
limitsUseGlobal: 'Use global',
limitsNoLimit: 'No limit',
productNamePrefix: 'Product Name Prefix',
productNameSuffix: 'Product Name Suffix',
preview: 'Preview',
loadBalanceStrategy: 'Load Balance Strategy',
strategyRoundRobin: 'Round Robin',
strategyLeastAmount: 'Least Daily Amount',
providerManagement: 'Provider Management',
providerManagementDesc: 'Manage payment provider instances',
createProvider: 'Add Provider',
editProvider: 'Edit Provider',
deleteProvider: 'Delete Provider',
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
providerName: 'Provider Name',
providerKey: 'Provider Type',
selectProviderKey: 'Select Provider Type',
providerConfig: 'Credentials',
noProviders: 'No provider instances configured',
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
},
smtp: { smtp: {
title: 'SMTP Settings', title: 'SMTP Settings',
description: 'Configure email sending for verification codes', description: 'Configure email sending for verification codes',
...@@ -5074,4 +5184,263 @@ export default { ...@@ -5074,4 +5184,263 @@ export default {
} }
}, },
// Payment System
payment: {
title: 'Recharge / Subscription',
amountLabel: 'Amount',
quickAmounts: 'Quick Amounts',
customAmount: 'Custom Amount',
enterAmount: 'Enter amount',
paymentMethod: 'Payment Method',
fee: 'Fee',
actualPay: 'Actual Payment',
createOrder: 'Confirm Payment',
methods: {
easypay: 'EasyPay',
alipay: 'Alipay',
wxpay: 'WeChat Pay',
stripe: 'Stripe',
card: 'Card',
link: 'Link',
alipay_direct: 'Alipay (Direct)',
wxpay_direct: 'WeChat Pay (Direct)',
},
status: {
pending: 'Pending',
paid: 'Paid',
recharging: 'Recharging',
completed: 'Completed',
expired: 'Expired',
cancelled: 'Cancelled',
failed: 'Failed',
refund_requested: 'Refund Requested',
refunding: 'Refunding',
refunded: 'Refunded',
partially_refunded: 'Partially Refunded',
refund_failed: 'Refund Failed',
},
qr: {
scanToPay: 'Scan to Pay',
scanAlipay: 'Alipay QR Payment',
scanWxpay: 'WeChat QR Payment',
scanAlipayHint: 'Open Alipay on your phone and scan the QR code to pay',
scanWxpayHint: 'Open WeChat on your phone and scan the QR code to pay',
payInNewWindow: 'Complete Payment in New Window',
payInNewWindowHint: 'The payment page has opened in a new window. Please complete the payment there and return to this page.',
openPayWindow: 'Reopen Payment Page',
expiresIn: 'Expires in',
expired: 'Order Expired',
expiredDesc: 'This order has expired. Please create a new one.',
cancelled: 'Order Cancelled',
cancelledDesc: 'You have cancelled this payment.',
waitingPayment: 'Waiting for payment...',
cancelOrder: 'Cancel Order',
},
orders: {
title: 'My Orders',
empty: 'No orders yet',
orderId: 'Order ID',
orderNo: 'Order No.',
amount: 'Amount',
payAmount: 'Paid',
status: 'Status',
paymentMethod: 'Payment Method',
createdAt: 'Created',
cancel: 'Cancel Order',
userId: 'User ID',
orderType: 'Order Type',
actions: 'Actions',
requestRefund: 'Request Refund',
},
result: {
success: 'Payment Successful',
subscriptionSuccess: 'Subscription Successful',
failed: 'Payment Failed',
backToRecharge: 'Back to Recharge',
viewOrders: 'View Orders',
},
currentBalance: 'Current Balance',
rechargeAccount: 'Recharge Account',
activeSubscription: 'Active Subscription',
noActiveSubscription: 'No active subscription',
tabTopUp: 'Top Up',
tabSubscribe: 'Subscribe',
noPlans: 'No subscription plans available',
notAvailable: 'Top-up is currently unavailable',
confirmSubscription: 'Confirm Subscription',
confirmCancel: 'Are you sure you want to cancel this order?',
amountTooLow: 'Minimum amount is {min}',
amountTooHigh: 'Maximum amount is {max}',
amountNoMethod: 'No payment method available for this amount',
refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please describe your refund reason',
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
stripeMissingParams: 'Missing order ID or client secret',
stripeNotConfigured: 'Stripe is not configured',
errors: {
tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.',
cancelRateLimited: 'Too many cancellations. Please try again later.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
},
stripePay: 'Pay Now',
stripeSuccessProcessing: 'Payment successful, processing your order...',
stripePopup: {
redirecting: 'Redirecting to payment page...',
loadingQr: 'Loading WeChat Pay QR code...',
timeout: 'Timed out waiting for payment credentials, please retry',
qrFailed: 'Failed to get WeChat Pay QR code',
},
subscribeNow: 'Subscribe Now',
renewNow: 'Renew',
selectPlan: 'Select Plan',
planFeatures: 'Features',
planCard: {
rate: 'Rate',
dailyLimit: 'Daily',
weeklyLimit: 'Weekly',
monthlyLimit: 'Monthly',
quota: 'Quota',
unlimited: 'Unlimited',
models: 'Models',
},
days: 'days',
months: 'months',
years: 'years',
oneMonth: '1 Month',
oneYear: '1 Year',
perMonth: 'month',
perYear: 'year',
admin: {
tabs: {
overview: 'Overview',
orders: 'Orders',
channels: 'Channels',
plans: 'Plans',
},
todayRevenue: 'Today Revenue',
totalRevenue: 'Total Revenue',
todayOrders: 'Today Orders',
orderCount: 'Order Count',
avgAmount: 'Average Amount',
revenue: 'Revenue',
dailyRevenue: 'Daily Revenue',
paymentDistribution: 'Payment Distribution',
colUser: 'User',
topUsers: 'Top Users',
noData: 'No data',
days: 'days',
weeks: 'weeks',
months: 'months',
searchOrders: 'Search orders...',
allStatuses: 'All Statuses',
allPaymentTypes: 'All Payment Types',
allOrderTypes: 'All Order Types',
orderDetail: 'Order Detail',
orderType: 'Order Type',
orders: 'Orders',
balanceOrder: 'Balance Top-Up',
subscriptionOrder: 'Subscription',
paidAt: 'Paid At',
completedAt: 'Completed At',
expiresAt: 'Expires At',
feeRate: 'Fee Rate',
refund: 'Refund',
refundOrder: 'Refund Order',
refundAmount: 'Refund Amount',
maxRefundable: 'Max Refundable',
refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please enter refund reason',
confirmRefund: 'Confirm Refund',
refundSuccess: 'Refund successful',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
alreadyRefunded: 'Already Refunded',
deductBalance: 'Deduct Balance',
deductBalanceHint: 'Subtract recharged amount from user balance',
userBalance: 'User Balance',
orderAmount: 'Order Amount',
insufficientBalance: 'Insufficient balance — will deduct to $0',
noDeduction: 'Will NOT deduct user balance',
forceRefund: 'Force refund (ignore balance check)',
orderCancelled: 'Order Cancelled',
retry: 'Retry',
retrySuccess: 'Retry successful',
approveRefund: 'Approve Refund',
retryRefund: 'Retry Refund',
refundRequestInfo: 'Refund Request Info',
refundRequestedAt: 'Requested At',
refundRequestedBy: 'Requested By',
refundRequestReason: 'Request Reason',
auditLogs: 'Audit Logs',
operator: 'Operator',
channelName: 'Channel Name',
channelDescription: 'Channel Description',
createChannel: 'Create Channel',
editChannel: 'Edit Channel',
deleteChannel: 'Delete Channel',
deleteChannelConfirm: 'Are you sure you want to delete this channel?',
planName: 'Plan Name',
planDescription: 'Plan Description',
createPlan: 'Create Plan',
editPlan: 'Edit Plan',
deletePlan: 'Delete Plan',
deletePlanConfirm: 'Are you sure you want to delete this plan?',
originalPrice: 'Original Price',
price: 'Price',
validityDays: 'Validity (days)',
validityUnit: 'Validity Unit',
sortOrder: 'Sort Order',
forSale: 'For Sale',
onSale: 'On Sale',
offSale: 'Off Sale',
group: 'Group',
groupId: 'Group ID',
features: 'Features',
featuresHint: 'One feature per line',
featuresPlaceholder: 'Enter plan features...',
providerManagement: 'Provider Management',
providerManagementDesc: 'Manage payment provider instances',
createProvider: 'Create Provider',
editProvider: 'Edit Provider',
deleteProvider: 'Delete Provider',
deleteProviderConfirm: 'Are you sure you want to delete this provider?',
providerName: 'Provider Name',
providerKey: 'Provider Key',
selectProviderKey: 'Select Provider Key',
providerConfig: 'Provider Config',
noProviders: 'No providers configured',
noProvidersHint: 'Create a provider instance to start accepting payments',
supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Select the payment types this provider supports',
rateMultiplier: 'Rate Multiplier',
dashboardTitle: 'Payment Dashboard',
dashboardDesc: 'Recharge order analytics and insights',
daySuffix: 'd',
paymentConfigTitle: 'Payment Config',
paymentConfigDesc: 'Configure payment providers and settings',
plansPageTitle: 'Subscription Plans',
plansPageDesc: 'Manage subscription plan configuration',
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
rateMultiplierLabel: 'Rate',
dailyLimit: 'Daily Limit',
weeklyLimit: 'Weekly Limit',
monthlyLimit: 'Monthly Limit',
unlimited: 'Unlimited',
searchUserSubs: 'Search user subscriptions...',
daily: 'D',
weekly: 'W',
monthly: 'M',
subsStatus: {
active: 'Active',
expired: 'Expired',
revoked: 'Revoked',
},
},
},
} }
...@@ -308,6 +308,8 @@ export default { ...@@ -308,6 +308,8 @@ export default {
chooseFile: '选择文件', chooseFile: '选择文件',
notAvailable: '不可用', notAvailable: '不可用',
now: '现在', now: '现在',
today: '今天',
tomorrow: '明天',
unknown: '未知', unknown: '未知',
minutes: '分钟', minutes: '分钟',
time: { time: {
...@@ -353,7 +355,11 @@ export default { ...@@ -353,7 +355,11 @@ export default {
mySubscriptions: '我的订阅', mySubscriptions: '我的订阅',
buySubscription: '充值/订阅', buySubscription: '充值/订阅',
docs: '文档', docs: '文档',
sora: 'Sora 创作' myOrders: '我的订单',
orderManagement: '订单管理',
paymentDashboard: '支付概览',
paymentConfig: '支付配置',
paymentPlans: '订阅套餐'
}, },
// Auth // Auth
...@@ -2234,6 +2240,7 @@ export default { ...@@ -2234,6 +2240,7 @@ export default {
rateLimited: '限流中', rateLimited: '限流中',
overloaded: '过载中', overloaded: '过载中',
tempUnschedulable: '临时不可调度', tempUnschedulable: '临时不可调度',
unschedulable: '不可调度',
rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复', rateLimitedUntil: '限流中,当前不参与调度,预计 {time} 自动恢复',
rateLimitedAutoResume: '{time} 自动恢复', rateLimitedAutoResume: '{time} 自动恢复',
modelRateLimitedUntil: '{model} 限流至 {time}', modelRateLimitedUntil: '{model} 限流至 {time}',
...@@ -4346,7 +4353,7 @@ export default { ...@@ -4346,7 +4353,7 @@ export default {
gateway: '网关服务', gateway: '网关服务',
email: '邮件设置', email: '邮件设置',
backup: '数据备份', backup: '数据备份',
data: 'Sora 存储', payment: '支付设置',
}, },
emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledTitle: '邮箱验证未启用',
emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。',
...@@ -4514,6 +4521,15 @@ export default { ...@@ -4514,6 +4521,15 @@ export default {
apiBaseUrl: 'API 端点地址', apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlPlaceholder: 'https://api.example.com',
tablePreferencesTitle: '通用表格设置',
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
tableDefaultPageSize: '默认每页条数',
tableDefaultPageSizeHint: '必须为 5-1000 之间的整数',
tablePageSizeOptions: '可选每页条数列表',
tablePageSizeOptionsPlaceholder: '10, 20, 50, 100',
tablePageSizeOptionsHint: '使用英文逗号分隔,取值范围 5-1000,保存时会自动去重并排序',
tableDefaultPageSizeRangeError: '默认每页条数必须在 {min}-{max} 之间',
tablePageSizeOptionsFormatError: '可选每页条数格式无效,请输入 {min}-{max} 之间的整数并用英文逗号分隔',
customEndpoints: { customEndpoints: {
title: '自定义端点', title: '自定义端点',
description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制', description: '添加额外的 API 端点地址,用户可在「API Keys」页面快速复制',
...@@ -4589,6 +4605,100 @@ export default { ...@@ -4589,6 +4605,100 @@ export default {
moveUp: '上移', moveUp: '上移',
moveDown: '下移', moveDown: '下移',
}, },
payment: {
title: '支付设置',
description: '配置支付系统选项',
enabled: '启用支付',
enabledHint: '启用或禁用支付系统',
enabledPaymentTypes: '启用的服务商',
enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例',
minAmount: '最低金额',
maxAmount: '最高金额',
dailyLimit: '每日限额',
orderTimeout: '订单超时时间',
orderTimeoutHint: '单位:分钟,至少 1 分钟',
maxPendingOrders: '最大待支付订单数',
cancelRateLimit: '限制取消频率',
cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单',
cancelRateLimitEvery: '',
cancelRateLimitAllowMax: '最多',
cancelRateLimitTimes: '',
cancelRateLimitWindow: '时间窗口',
cancelRateLimitUnit: '周期',
cancelRateLimitMax: '最大取消次数',
cancelRateLimitUnitMinute: '分钟',
cancelRateLimitUnitHour: '小时',
cancelRateLimitUnitDay: '',
cancelRateLimitWindowMode: '窗口模式',
cancelRateLimitWindowModeRolling: '滚动',
cancelRateLimitWindowModeFixed: '固定',
helpText: '帮助文本',
helpImageUrl: '帮助图片链接',
manageProviders: '管理服务商',
balancePaymentDisabled: '禁用余额充值',
noLimit: '留空表示不限制',
helpImage: '帮助图片',
helpImagePlaceholder: '上传或输入图片链接',
helpTextPlaceholder: '输入帮助说明文本...',
providerEasypay: '易支付',
providerAlipay: '支付宝官方',
providerWxpay: '微信官方',
providerStripe: 'Stripe',
typeDisabled: '类型已禁用',
enableTypesFirst: '请先在上方启用至少一种服务商',
easypayRedirect: '跳转',
paymentMode: '支付模式',
modeRedirect: '跳转',
modeQRCode: '二维码',
modePopup: '弹窗',
validationNameRequired: '服务商名称不能为空',
validationTypesRequired: '请至少选择一种支持的支付方式',
validationFieldRequired: '{field} 不能为空',
field_apiBase: 'API 基础地址',
field_notifyUrl: '异步通知地址',
field_returnUrl: '同步跳转地址',
callbackBaseUrl: '回调基础地址',
field_privateKey: '私钥',
field_publicKey: '公钥',
field_mchId: '商户号',
field_apiV3Key: 'API v3 密钥',
field_publicKeyId: '公钥 ID',
field_certSerial: '证书序列号',
field_secretKey: '密钥',
field_publishableKey: '公开密钥',
field_webhookSecret: 'Webhook 密钥',
field_cid: '支付渠道 ID',
field_cidAlipay: '支付宝渠道 ID',
field_cidWxpay: '微信渠道 ID',
stripeWebhookHint: '请在 Stripe Dashboard 中将以下地址配置为 Webhook 端点:',
limitsTitle: '限额配置',
limitSingleMin: '单笔最低',
limitSingleMax: '单笔最高',
limitDaily: '每日限额',
limitsHint: '全部留空使用全局配置,部分填写时留空项表示不限制',
limitsUseGlobal: '使用全局配置',
limitsNoLimit: '不限制',
productNamePrefix: '商品名前缀',
productNameSuffix: '商品名后缀',
preview: '预览',
loadBalanceStrategy: '负载均衡策略',
strategyRoundRobin: '轮询',
strategyLeastAmount: '最少金额',
providerManagement: '服务商管理',
providerManagementDesc: '管理支付服务商实例',
createProvider: '添加服务商',
editProvider: '编辑服务商',
deleteProvider: '删除服务商',
deleteProviderConfirm: '确定要删除此服务商吗?',
providerName: '服务商名称',
providerKey: '服务商类型',
selectProviderKey: '选择服务商类型',
providerConfig: '凭证配置',
noProviders: '暂无服务商实例',
supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
},
smtp: { smtp: {
title: 'SMTP 设置', title: 'SMTP 设置',
description: '配置用于发送验证码的邮件服务', description: '配置用于发送验证码的邮件服务',
...@@ -5262,4 +5372,263 @@ export default { ...@@ -5262,4 +5372,263 @@ export default {
} }
}, },
// Payment System
payment: {
title: '充值/订阅',
amountLabel: '充值金额',
quickAmounts: '快捷金额',
customAmount: '自定义金额',
enterAmount: '输入金额',
paymentMethod: '支付方式',
fee: '手续费',
actualPay: '实付金额',
createOrder: '确认支付',
methods: {
easypay: '易支付',
alipay: '支付宝',
wxpay: '微信支付',
stripe: 'Stripe',
card: '银行卡',
link: 'Link',
alipay_direct: '支付宝(直连)',
wxpay_direct: '微信支付(直连)',
},
status: {
pending: '待支付',
paid: '已支付',
recharging: '充值中',
completed: '已完成',
expired: '已过期',
cancelled: '已取消',
failed: '失败',
refund_requested: '退款申请中',
refunding: '退款中',
refunded: '已退款',
partially_refunded: '部分退款',
refund_failed: '退款失败',
},
qr: {
scanToPay: '请扫码支付',
scanAlipay: '支付宝扫码支付',
scanWxpay: '微信扫码支付',
scanAlipayHint: '请使用手机打开支付宝,扫描二维码完成支付',
scanWxpayHint: '请使用手机打开微信,扫描二维码完成支付',
payInNewWindow: '请在新窗口中完成支付',
payInNewWindowHint: '支付页面已在新窗口打开,请在新窗口中完成支付后返回此页面',
openPayWindow: '重新打开支付页面',
expiresIn: '剩余支付时间',
expired: '订单已过期',
expiredDesc: '订单已超时,请重新创建订单',
cancelled: '订单已取消',
cancelledDesc: '您已取消本次支付',
waitingPayment: '等待支付...',
cancelOrder: '取消订单',
},
orders: {
title: '我的订单',
empty: '暂无订单',
orderId: '订单 ID',
orderNo: '订单编号',
amount: '金额',
payAmount: '实付',
status: '状态',
paymentMethod: '支付方式',
createdAt: '创建时间',
cancel: '取消订单',
userId: '用户 ID',
orderType: '订单类型',
actions: '操作',
requestRefund: '申请退款',
},
result: {
success: '支付成功',
subscriptionSuccess: '订阅成功',
failed: '支付失败',
backToRecharge: '返回充值',
viewOrders: '查看订单',
},
currentBalance: '当前余额',
rechargeAccount: '充值账户',
activeSubscription: '当前订阅',
noActiveSubscription: '暂无有效订阅',
tabTopUp: '充值',
tabSubscribe: '订阅',
noPlans: '暂无可用订阅套餐',
notAvailable: '充值功能暂未开放',
confirmSubscription: '确认订阅',
confirmCancel: '确定要取消此订单吗?',
amountTooLow: '最低金额为 {min}',
amountTooHigh: '最高金额为 {max}',
amountNoMethod: '该金额没有可用的支付方式',
refundReason: '退款原因',
refundReasonPlaceholder: '请描述您的退款原因',
stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
stripeMissingParams: '缺少订单ID或支付密钥',
stripeNotConfigured: 'Stripe 未配置',
errors: {
tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单',
cancelRateLimited: '取消订单过于频繁,请稍后再试',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
},
stripePay: '立即支付',
stripeSuccessProcessing: '支付成功,正在处理订单...',
stripePopup: {
redirecting: '正在跳转到支付页面...',
loadingQr: '正在获取微信支付二维码...',
timeout: '等待支付凭证超时,请重试',
qrFailed: '未能获取微信支付二维码',
},
subscribeNow: '立即开通',
renewNow: '续费',
selectPlan: '选择套餐',
planFeatures: '功能特性',
planCard: {
rate: '倍率',
dailyLimit: '日限额',
weeklyLimit: '周限额',
monthlyLimit: '月限额',
quota: '配额',
unlimited: '无限制',
models: '模型',
},
days: '',
months: '个月',
years: '',
oneMonth: '1 个月',
oneYear: '1 年',
perMonth: '',
perYear: '',
admin: {
tabs: {
overview: '概览',
orders: '订单管理',
channels: '支付渠道',
plans: '订阅套餐',
},
todayRevenue: '今日收入',
totalRevenue: '总收入',
todayOrders: '今日订单',
orderCount: '订单数',
avgAmount: '平均金额',
revenue: '收入',
dailyRevenue: '每日收入',
paymentDistribution: '支付方式分布',
colUser: '用户',
topUsers: '消费排行',
noData: '暂无数据',
days: '',
weeks: '',
months: '',
searchOrders: '搜索订单...',
allStatuses: '全部状态',
allPaymentTypes: '全部支付方式',
allOrderTypes: '全部订单类型',
orderDetail: '订单详情',
orderType: '订单类型',
orders: '订单',
balanceOrder: '余额充值',
subscriptionOrder: '订阅',
paidAt: '支付时间',
completedAt: '完成时间',
expiresAt: '过期时间',
feeRate: '手续费率',
refund: '退款',
refundOrder: '退款订单',
refundAmount: '退款金额',
maxRefundable: '最大可退金额',
refundReason: '退款原因',
refundReasonPlaceholder: '请输入退款原因',
confirmRefund: '确认退款',
refundSuccess: '退款成功',
refundInfo: '退款信息',
refundEnabled: '允许退款',
alreadyRefunded: '已退款',
deductBalance: '扣除余额',
deductBalanceHint: '从用户余额中扣回充值金额',
userBalance: '用户余额',
orderAmount: '订单金额',
insufficientBalance: '余额不足,将扣至 $0',
noDeduction: '将不扣除用户余额',
forceRefund: '强制退款(忽略余额检查)',
orderCancelled: '订单已取消',
retry: '重试',
retrySuccess: '重试成功',
approveRefund: '批准退款',
retryRefund: '重试退款',
refundRequestInfo: '退款申请信息',
refundRequestedAt: '申请时间',
refundRequestedBy: '申请人',
refundRequestReason: '申请原因',
auditLogs: '操作日志',
operator: '操作人',
channelName: '渠道名称',
channelDescription: '渠道描述',
createChannel: '创建渠道',
editChannel: '编辑渠道',
deleteChannel: '删除渠道',
deleteChannelConfirm: '确定要删除此渠道吗?',
planName: '套餐名称',
planDescription: '套餐描述',
createPlan: '创建套餐',
editPlan: '编辑套餐',
deletePlan: '删除套餐',
deletePlanConfirm: '确定要删除此套餐吗?',
originalPrice: '原价',
price: '价格',
validityDays: '有效期(天)',
validityUnit: '有效期单位',
sortOrder: '排序',
forSale: '上架状态',
onSale: '上架',
offSale: '下架',
group: '分组',
groupId: '分组 ID',
features: '功能特性',
featuresHint: '每行一个特性',
featuresPlaceholder: '输入套餐特性...',
providerManagement: '服务商管理',
providerManagementDesc: '管理支付服务商实例',
createProvider: '创建服务商',
editProvider: '编辑服务商',
deleteProvider: '删除服务商',
deleteProviderConfirm: '确定要删除此服务商吗?',
providerName: '服务商名称',
providerKey: '服务商标识',
selectProviderKey: '选择服务商标识',
providerConfig: '服务商配置',
noProviders: '暂无服务商',
noProvidersHint: '创建一个服务商实例以开始接受支付',
supportedTypes: '支持的支付方式',
supportedTypesHint: '选择此服务商支持的支付方式',
rateMultiplier: '费率倍数',
dashboardTitle: '支付概览',
dashboardDesc: '充值订单统计与分析',
daySuffix: '',
paymentConfigTitle: '支付配置',
paymentConfigDesc: '管理支付服务商与相关设置',
plansPageTitle: '订阅套餐管理',
plansPageDesc: '管理订阅套餐配置',
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
rateMultiplierLabel: '倍率',
dailyLimit: '日限额',
weeklyLimit: '周限额',
monthlyLimit: '月限额',
unlimited: '无限制',
searchUserSubs: '搜索用户订阅...',
daily: '',
weekly: '',
monthly: '',
subsStatus: {
active: '生效中',
expired: '已过期',
revoked: '已撤销',
},
},
},
} }
...@@ -201,13 +201,73 @@ const routes: RouteRecordRaw[] = [ ...@@ -201,13 +201,73 @@ const routes: RouteRecordRaw[] = [
{ {
path: '/purchase', path: '/purchase',
name: 'PurchaseSubscription', name: 'PurchaseSubscription',
component: () => import('@/views/user/PurchaseSubscriptionView.vue'), component: () => import('@/views/user/PaymentView.vue'),
meta: { meta: {
requiresAuth: true, requiresAuth: true,
requiresAdmin: false, requiresAdmin: false,
title: 'Purchase Subscription', title: 'Purchase Subscription',
titleKey: 'purchase.title', titleKey: 'nav.buySubscription',
descriptionKey: 'purchase.description' descriptionKey: 'purchase.description',
requiresPayment: true
}
},
{
path: '/orders',
name: 'OrderList',
component: () => import('@/views/user/UserOrdersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'My Orders',
titleKey: 'nav.myOrders',
requiresPayment: true
}
},
{
path: '/payment/qrcode',
name: 'PaymentQRCode',
component: () => import('@/views/user/PaymentQRCodeView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Payment',
titleKey: 'payment.qr.scanToPay',
requiresPayment: true
}
},
{
path: '/payment/result',
name: 'PaymentResult',
component: () => import('@/views/user/PaymentResultView.vue'),
meta: {
requiresAuth: false,
requiresAdmin: false,
title: 'Payment Result',
titleKey: 'payment.result.success',
requiresPayment: false
}
},
{
path: '/payment/stripe',
name: 'StripePayment',
component: () => import('@/views/user/StripePaymentView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Stripe Payment',
titleKey: 'payment.stripePay',
requiresPayment: true
}
},
{
path: '/payment/stripe-popup',
name: 'StripePopup',
component: () => import('@/views/user/StripePopupView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: false,
title: 'Payment',
requiresPayment: true
} }
}, },
{ {
...@@ -384,6 +444,45 @@ const routes: RouteRecordRaw[] = [ ...@@ -384,6 +444,45 @@ const routes: RouteRecordRaw[] = [
} }
}, },
// ==================== Payment Admin Routes ====================
{
path: '/admin/orders/dashboard',
name: 'AdminPaymentDashboard',
component: () => import('@/views/admin/orders/AdminPaymentDashboardView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Payment Dashboard',
titleKey: 'nav.paymentDashboard',
requiresPayment: true
}
},
{
path: '/admin/orders',
name: 'AdminOrders',
component: () => import('@/views/admin/orders/AdminOrdersView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Order Management',
titleKey: 'nav.orderManagement',
requiresPayment: true
}
},
{
path: '/admin/orders/plans',
name: 'AdminPaymentPlans',
component: () => import('@/views/admin/orders/AdminPaymentPlansView.vue'),
meta: {
requiresAuth: true,
requiresAdmin: true,
title: 'Subscription Plans',
titleKey: 'nav.paymentPlans',
requiresPayment: true
}
},
// ==================== 404 Not Found ==================== // ==================== 404 Not Found ====================
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
...@@ -500,6 +599,16 @@ router.beforeEach((to, _from, next) => { ...@@ -500,6 +599,16 @@ router.beforeEach((to, _from, next) => {
return return
} }
// Check payment requirement (internal payment system only)
if (to.meta.requiresPayment) {
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
if (!paymentEnabled) {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
}
// 简易模式下限制访问某些页面 // 简易模式下限制访问某些页面
if (authStore.isSimpleMode) { if (authStore.isSimpleMode) {
const restrictedPaths = [ const restrictedPaths = [
......
...@@ -42,5 +42,21 @@ declare module 'vue-router' { ...@@ -42,5 +42,21 @@ declare module 'vue-router' {
* @default false * @default false
*/ */
hideInMenu?: boolean hideInMenu?: boolean
/**
* Whether this route requires internal payment system to be enabled
* @default false
*/
requiresPayment?: boolean
/**
* i18n key for the page title
*/
titleKey?: string
/**
* i18n key for the page description
*/
descriptionKey?: string
} }
} }
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { getPublicSettings } from '@/api/auth'
// Mock API 模块 // Mock API 模块
vi.mock('@/api/admin/system', () => ({ vi.mock('@/api/admin/system', () => ({
...@@ -15,12 +16,14 @@ describe('useAppStore', () => { ...@@ -15,12 +16,14 @@ describe('useAppStore', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.useFakeTimers() vi.useFakeTimers()
localStorage.clear()
// 清除 window.__APP_CONFIG__ // 清除 window.__APP_CONFIG__
delete (window as any).__APP_CONFIG__ delete (window as any).__APP_CONFIG__
}) })
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
localStorage.clear()
}) })
// --- Toast 消息管理 --- // --- Toast 消息管理 ---
...@@ -291,5 +294,43 @@ describe('useAppStore', () => { ...@@ -291,5 +294,43 @@ describe('useAppStore', () => {
expect(store.publicSettingsLoaded).toBe(false) expect(store.publicSettingsLoaded).toBe(false)
expect(store.cachedPublicSettings).toBeNull() expect(store.cachedPublicSettings).toBeNull()
}) })
it('fetchPublicSettings(force) 会同步更新运行时注入配置', async () => {
vi.mocked(getPublicSettings).mockResolvedValue({
registration_enabled: false,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: 'Updated Site',
site_logo: '',
site_subtitle: '',
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
purchase_subscription_enabled: false,
purchase_subscription_url: '',
table_default_page_size: 1000,
table_page_size_options: [20, 100, 1000],
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
backend_mode_enabled: false,
version: '1.0.0'
})
const store = useAppStore()
await store.fetchPublicSettings(true)
expect((window as any).__APP_CONFIG__.table_default_page_size).toBe(1000)
expect((window as any).__APP_CONFIG__.table_page_size_options).toEqual([20, 100, 1000])
expect(localStorage.getItem('table-page-size')).toBeNull()
expect(localStorage.getItem('table-page-size-source')).toBeNull()
})
}) })
}) })
...@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -48,6 +48,7 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true)) const opsMonitoringEnabled = ref(readCachedBool('ops_monitoring_enabled_cached', true))
const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true)) const opsRealtimeMonitoringEnabled = ref(readCachedBool('ops_realtime_monitoring_enabled_cached', true))
const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto')) const opsQueryModeDefault = ref(readCachedString('ops_query_mode_default_cached', 'auto'))
const paymentEnabled = ref(readCachedBool('payment_enabled_cached', false))
const customMenuItems = ref<CustomMenuItem[]>([]) const customMenuItems = ref<CustomMenuItem[]>([])
async function fetch(force = false): Promise<void> { async function fetch(force = false): Promise<void> {
...@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -56,7 +57,10 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loading.value = true loading.value = true
try { try {
const settings = await adminAPI.settings.getSettings() const [settings, paymentConfigResp] = await Promise.all([
adminAPI.settings.getSettings(),
adminAPI.payment.getConfig()
])
opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true opsMonitoringEnabled.value = settings.ops_monitoring_enabled ?? true
writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value) writeCachedBool('ops_monitoring_enabled_cached', opsMonitoringEnabled.value)
...@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -68,6 +72,9 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : [] customMenuItems.value = Array.isArray(settings.custom_menu_items) ? settings.custom_menu_items : []
paymentEnabled.value = paymentConfigResp.data?.enabled ?? false
writeCachedBool('payment_enabled_cached', paymentEnabled.value)
loaded.value = true loaded.value = true
} catch (err) { } catch (err) {
// Keep cached/default value: do not "flip" the UI based on a transient fetch failure. // Keep cached/default value: do not "flip" the UI based on a transient fetch failure.
...@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -90,6 +97,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
loaded.value = true loaded.value = true
} }
function setPaymentEnabledLocal(value: boolean) {
paymentEnabled.value = value
writeCachedBool('payment_enabled_cached', value)
loaded.value = true
}
function setOpsQueryModeDefaultLocal(value: string) { function setOpsQueryModeDefaultLocal(value: string) {
opsQueryModeDefault.value = value || 'auto' opsQueryModeDefault.value = value || 'auto'
writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value) writeCachedString('ops_query_mode_default_cached', opsQueryModeDefault.value)
...@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => { ...@@ -126,10 +139,12 @@ export const useAdminSettingsStore = defineStore('adminSettings', () => {
opsMonitoringEnabled, opsMonitoringEnabled,
opsRealtimeMonitoringEnabled, opsRealtimeMonitoringEnabled,
opsQueryModeDefault, opsQueryModeDefault,
paymentEnabled,
customMenuItems, customMenuItems,
fetch, fetch,
setOpsMonitoringEnabledLocal, setOpsMonitoringEnabledLocal,
setOpsRealtimeMonitoringEnabledLocal, setOpsRealtimeMonitoringEnabledLocal,
setPaymentEnabledLocal,
setOpsQueryModeDefaultLocal setOpsQueryModeDefaultLocal
} }
}) })
...@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => { ...@@ -284,6 +284,9 @@ export const useAppStore = defineStore('app', () => {
* Apply settings to store state (internal helper to avoid code duplication) * Apply settings to store state (internal helper to avoid code duplication)
*/ */
function applySettings(config: PublicSettings): void { function applySettings(config: PublicSettings): void {
if (typeof window !== 'undefined') {
window.__APP_CONFIG__ = { ...config }
}
cachedPublicSettings.value = config cachedPublicSettings.value = config
siteName.value = config.site_name || 'Sub2API' siteName.value = config.site_name || 'Sub2API'
siteLogo.value = config.site_logo || '' siteLogo.value = config.site_logo || ''
...@@ -327,14 +330,14 @@ export const useAppStore = defineStore('app', () => { ...@@ -327,14 +330,14 @@ export const useAppStore = defineStore('app', () => {
doc_url: docUrl.value, doc_url: docUrl.value,
home_content: '', home_content: '',
hide_ccs_import_button: false, hide_ccs_import_button: false,
purchase_subscription_enabled: false, payment_enabled: false,
purchase_subscription_url: '', table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [], custom_endpoints: [],
linuxdo_oauth_enabled: false, linuxdo_oauth_enabled: false,
oidc_oauth_enabled: false, oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC', oidc_oauth_provider_name: 'OIDC',
sora_client_enabled: false,
backend_mode_enabled: false, backend_mode_enabled: false,
version: siteVersion.value version: siteVersion.value
} }
......
...@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings' ...@@ -9,6 +9,7 @@ export { useAdminSettingsStore } from './adminSettings'
export { useSubscriptionStore } from './subscriptions' export { useSubscriptionStore } from './subscriptions'
export { useOnboardingStore } from './onboarding' export { useOnboardingStore } from './onboarding'
export { useAnnouncementStore } from './announcements' export { useAnnouncementStore } from './announcements'
export { usePaymentStore } from './payment'
// Re-export types for convenience // Re-export types for convenience
export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types' export type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
......
/**
* Payment Store
* Manages payment configuration, current order state, and subscription plans
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { paymentAPI } from '@/api/payment'
import type { PaymentConfig, PaymentOrder, SubscriptionPlan, CreateOrderRequest } from '@/types/payment'
export const usePaymentStore = defineStore('payment', () => {
// ==================== State ====================
/** Payment configuration from backend */
const config = ref<PaymentConfig | null>(null)
/** Currently active order (for payment flow) */
const currentOrder = ref<PaymentOrder | null>(null)
/** Available subscription plans */
const plans = ref<SubscriptionPlan[]>([])
const configLoading = ref(false)
const configLoaded = ref(false)
// ==================== Actions ====================
/** Fetch payment configuration */
async function fetchConfig(force = false): Promise<PaymentConfig | null> {
if (configLoaded.value && !force) return config.value
if (configLoading.value) return config.value
configLoading.value = true
try {
const response = await paymentAPI.getConfig()
config.value = response.data
configLoaded.value = true
return config.value
} catch (error: unknown) {
console.error('[payment] Failed to fetch config:', error)
return null
} finally {
configLoading.value = false
}
}
/** Fetch available subscription plans */
async function fetchPlans(): Promise<SubscriptionPlan[]> {
try {
const response = await paymentAPI.getPlans()
// Backend returns features as newline-separated string; parse to array
plans.value = (response.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 || []),
}))
return plans.value
} catch (error: unknown) {
console.error('[payment] Failed to fetch plans:', error)
return []
}
}
/** Create a new order and set it as current */
async function createOrder(params: CreateOrderRequest) {
const response = await paymentAPI.createOrder(params)
return response.data
}
/** Poll order status by ID */
async function pollOrderStatus(orderId: number): Promise<PaymentOrder | null> {
try {
const response = await paymentAPI.getOrder(orderId)
const order = response.data
if (currentOrder.value?.id === orderId) {
currentOrder.value = order
}
return order
} catch (error: unknown) {
console.error('[payment] Failed to poll order status:', error)
return null
}
}
/** Clear current order state */
function clearCurrentOrder() {
currentOrder.value = null
}
return {
config,
currentOrder,
plans,
configLoading,
configLoaded,
fetchConfig,
fetchPlans,
createOrder,
pollOrderStatus,
clearCurrentOrder
}
})
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