Commit 0b746501 authored by 陈曦's avatar 陈曦
Browse files

1. merge upstream v0.1.113 2.提交migration相关文件

parents 45061102 be7551b9
...@@ -37,8 +37,20 @@ ...@@ -37,8 +37,20 @@
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ order.pay_amount.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">&#165;{{ baseAmount.toFixed(2) }}</span>
</div>
<div v-if="order.fee_rate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ feeAmount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-bold text-primary-600 dark:text-primary-400">&#165;{{ order.pay_amount.toFixed(2) }}</span>
</div>
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
...@@ -58,7 +70,7 @@ ...@@ -58,7 +70,7 @@
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
</div> </div>
<div v-if="returnInfo.money" class="flex justify-between"> <div v-if="returnInfo.money" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ returnInfo.money }}</span> <span class="font-medium text-gray-900 dark:text-white">&#165;{{ returnInfo.money }}</span>
</div> </div>
<div v-if="returnInfo.type" class="flex justify-between"> <div v-if="returnInfo.type" class="flex justify-between">
...@@ -104,6 +116,18 @@ const returnInfo = ref<ReturnInfo | null>(null) ...@@ -104,6 +116,18 @@ const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING']) const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
const baseAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return order.value?.pay_amount ?? 0
return Math.round((order.value.pay_amount / (1 + order.value.fee_rate / 100)) * 100) / 100
})
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return 0
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
})
const isSuccess = computed(() => { const isSuccess = computed(() => {
// Always prioritize actual order status from backend // Always prioritize actual order status from backend
if (order.value) { if (order.value) {
......
...@@ -28,7 +28,9 @@ ...@@ -28,7 +28,9 @@
<template v-else-if="paymentPhase === 'stripe'"> <template v-else-if="paymentPhase === 'stripe'">
<StripePaymentInline <StripePaymentInline
:order-id="paymentState.orderId" :order-id="paymentState.orderId"
:amount="paymentState.amount"
:client-secret="paymentState.clientSecret" :client-secret="paymentState.clientSecret"
:order-type="paymentState.orderType || undefined"
:publishable-key="checkout.stripe_publishable_key" :publishable-key="checkout.stripe_publishable_key"
:pay-amount="paymentState.payAmount" :pay-amount="paymentState.payAmount"
@success="onPaymentSuccess" @success="onPaymentSuccess"
...@@ -67,20 +69,27 @@ ...@@ -67,20 +69,27 @@
@select="selectedMethod = $event" @select="selectedMethod = $event"
/> />
</div> </div>
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6"> <div v-if="validAmount > 0" class="card p-6">
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.paymentAmount') }}</span>
<span class="text-gray-900 dark:text-white">${{ validAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <div v-if="feeRate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ feeAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"> <div v-if="feeRate > 0" class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ totalAmount.toFixed(2) }}</span> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
</div>
<div v-if="balanceRechargeMultiplier !== 1" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate <= 0 }">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.creditedBalance') }}</span>
<span class="text-gray-900 dark:text-white">${{ creditedAmount.toFixed(2) }}</span>
</div> </div>
<p v-if="balanceRechargeMultiplier !== 1" class="border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-dark-600 dark:text-gray-400">
{{ t('payment.rechargeRatePreview', { usd: balanceRechargeMultiplier.toFixed(2) }) }}
</p>
</div> </div>
</div> </div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge"> <button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
...@@ -88,7 +97,7 @@ ...@@ -88,7 +97,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }} {{ t('common.processing') }}
</span> </span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span> <span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
</button> </button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"> <div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p> <p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
...@@ -110,9 +119,9 @@ ...@@ -110,9 +119,9 @@
<!-- Price --> <!-- Price -->
<div class="flex items-baseline gap-2"> <div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500"> <span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
${{ selectedPlan.original_price }} ¥{{ selectedPlan.original_price }}
</span> </span>
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span> <span :class="['text-3xl font-bold', planTextClass]">¥{{ selectedPlan.price }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span> <span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div> </div>
<!-- Description --> <!-- Description -->
...@@ -156,15 +165,15 @@ ...@@ -156,15 +165,15 @@
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
<span class="text-gray-900 dark:text-white">${{ selectedPlan.price.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ selectedPlan.price.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">${{ subFeeAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ subFeeAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"> <div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span> <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">${{ subTotalAmount.toFixed(2) }}</span> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ subTotalAmount.toFixed(2) }}</span>
</div> </div>
</div> </div>
</div> </div>
...@@ -173,7 +182,7 @@ ...@@ -173,7 +182,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }} {{ t('common.processing') }}
</span> </span>
<span v-else>{{ t('payment.createOrder') }} ${{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span> <span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 ? subTotalAmount : selectedPlan.price).toFixed(2) }}</span>
</button> </button>
<button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button> <button class="btn btn-secondary w-full" @click="selectedPlan = null">{{ t('common.cancel') }}</button>
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"> <div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
...@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores' ...@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device' import { isMobileDevice } from '@/utils/device'
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment' import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue' import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
...@@ -302,11 +311,21 @@ const previewImage = ref('') ...@@ -302,11 +311,21 @@ const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe) // Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select') const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }) const paymentState = ref<{
orderId: number
amount: number
qrCode: string
expiresAt: string
paymentType: string
payUrl: string
clientSecret: string
payAmount: number
orderType: OrderType | ''
}>({ orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
function resetPayment() { function resetPayment() {
paymentPhase.value = 'select' paymentPhase.value = 'select'
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' } paymentState.value = { orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
} }
function onPaymentDone() { function onPaymentDone() {
...@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) { ...@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) {
// All checkout data from single API call // All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({ const checkout = ref<CheckoutInfoResponse>({
methods: {}, global_min: 0, global_max: 0, methods: {}, global_min: 0, global_max: 0,
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '', plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
}) })
const tabs = computed(() => { const tabs = computed(() => {
...@@ -354,6 +373,11 @@ const tabs = computed(() => { ...@@ -354,6 +373,11 @@ const tabs = computed(() => {
const enabledMethods = computed(() => Object.keys(checkout.value.methods)) const enabledMethods = computed(() => Object.keys(checkout.value.methods))
const validAmount = computed(() => amount.value ?? 0) const validAmount = computed(() => amount.value ?? 0)
const balanceRechargeMultiplier = computed(() => {
const multiplier = checkout.value.balance_recharge_multiplier
return multiplier > 0 ? multiplier : 1
})
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+ // Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const planGridClass = computed(() => { const planGridClass = computed(() => {
...@@ -390,7 +414,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() => ...@@ -390,7 +414,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() =>
}) })
) )
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0) const feeRate = computed(() => checkout.value?.recharge_fee_rate ?? 0)
const feeAmount = computed(() => const feeAmount = computed(() =>
feeRate.value > 0 && validAmount.value > 0 feeRate.value > 0 && validAmount.value > 0
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100 ? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
...@@ -518,7 +542,7 @@ async function confirmSubscribe() { ...@@ -518,7 +542,7 @@ async function confirmSubscribe() {
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id) await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
} }
async function createOrder(orderAmount: number, orderType: string, planId?: number) { async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) {
submitting.value = true submitting.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
...@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
if (result.client_secret) { if (result.client_secret) {
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed) // Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
paymentState.value = { paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: result.client_secret, payAmount: result.pay_amount, clientSecret: result.client_secret, payAmount: result.pay_amount,
orderType, orderType,
...@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
} else if (isMobileDevice() && result.pay_url) { } else if (isMobileDevice() && result.pay_url) {
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups) // Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState.value = { paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url, paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
...@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
} else if (result.qr_code) { } else if (result.qr_code) {
// QR mode: show QR code inline // QR mode: show QR code inline
paymentState.value = { paymentState.value = {
orderId: result.order_id, qrCode: result.qr_code, orderId: result.order_id, amount: result.amount, qrCode: result.qr_code,
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '', expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
...@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
// Redirect/popup mode: open payment URL, show waiting state inline // Redirect/popup mode: open payment URL, show waiting state inline
openWindow(result.pay_url) openWindow(result.pay_url)
paymentState.value = { paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url, paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
......
...@@ -14,6 +14,14 @@ ...@@ -14,6 +14,14 @@
</div> </div>
</div> </div>
<ProfileEditForm :initial-username="user?.username || ''" /> <ProfileEditForm :initial-username="user?.username || ''" />
<ProfileBalanceNotifyCard
v-if="user && balanceLowNotifyEnabled"
:enabled="user.balance_notify_enabled ?? true"
:threshold="user.balance_notify_threshold"
:extra-emails="user.balance_notify_extra_emails ?? []"
:system-default-threshold="systemDefaultThreshold"
:user-email="user.email"
/>
<ProfilePasswordForm /> <ProfilePasswordForm />
<ProfileTotpCard /> <ProfileTotpCard />
</div> </div>
...@@ -27,17 +35,20 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL ...@@ -27,17 +35,20 @@ import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppL
import StatCard from '@/components/common/StatCard.vue' import StatCard from '@/components/common/StatCard.vue'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfileBalanceNotifyCard from '@/components/user/profile/ProfileBalanceNotifyCard.vue'
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue' import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
import { Icon } from '@/components/icons' import { Icon } from '@/components/icons'
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user) const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
const contactInfo = ref('') const contactInfo = ref('')
const balanceLowNotifyEnabled = ref(false)
const systemDefaultThreshold = ref(0)
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) } const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) } const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) } const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch (error) { console.error('Failed to load contact info:', error) } }) onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || ''; balanceLowNotifyEnabled.value = s.balance_low_notify_enabled ?? false; systemDefaultThreshold.value = s.balance_low_notify_threshold ?? 0 } catch (error) { console.error('Failed to load settings:', error) } })
const formatCurrency = (v: number) => `$${v.toFixed(2)}` const formatCurrency = (v: number) => `$${v.toFixed(2)}`
</script> </script>
\ No newline at end of file
...@@ -192,7 +192,7 @@ ...@@ -192,7 +192,7 @@
<template #cell-billing_mode="{ row }"> <template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium" <span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium"
:class="getBillingModeBadgeClass(row.billing_mode)"> :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }} {{ getBillingModeLabel(row.billing_mode, t) }}
</span> </span>
</template> </template>
...@@ -447,13 +447,21 @@ ...@@ -447,13 +447,21 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span> <span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4"> <!-- Token billing: show unit prices per 1M tokens -->
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span> <template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === 'token'">
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
</div> <span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4"> <span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span> </div>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span> <div v-if="tooltipData && tooltipData.output_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
<span class="font-medium text-violet-300">{{ formatTokenPricePerMillion(tooltipData.output_cost, tooltipData.output_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
</template>
<!-- Per-request / image billing: show unit price -->
<div v-else class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ tooltipData.billing_mode === 'image' ? t('usage.imageUnitPrice') : t('usage.unitPrice') }}</span>
<span class="font-medium text-sky-300">${{ tooltipData.total_cost?.toFixed(6) || '0.000000' }}</span>
</div> </div>
<div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4"> <div v-if="tooltipData && tooltipData.cache_creation_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span> <span class="text-gray-400">{{ t('admin.usage.cacheCreationCost') }}</span>
...@@ -516,6 +524,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters' ...@@ -516,6 +524,7 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType' import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
...@@ -636,17 +645,6 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => { ...@@ -636,17 +645,6 @@ const getRequestTypeBadgeClass = (log: UsageLog): string => {
return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
} }
const getBillingModeLabel = (mode: string | null | undefined): string => {
if (mode === 'per_request') return t('admin.usage.billingModePerRequest')
if (mode === 'image') return t('admin.usage.billingModeImage')
return t('admin.usage.billingModeToken')
}
const getBillingModeBadgeClass = (mode: string | null | undefined): string => {
if (mode === 'per_request') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200'
if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
const getRequestTypeExportText = (log: UsageLog): string => { const getRequestTypeExportText = (log: UsageLog): string => {
const requestType = resolveUsageRequestType(log) const requestType = resolveUsageRequestType(log)
...@@ -858,7 +856,7 @@ const exportToCSV = async () => { ...@@ -858,7 +856,7 @@ const exportToCSV = async () => {
formatReasoningEffort(log.reasoning_effort), formatReasoningEffort(log.reasoning_effort),
log.inbound_endpoint || '', log.inbound_endpoint || '',
getRequestTypeExportText(log), getRequestTypeExportText(log),
getBillingModeLabel(log.billing_mode), getBillingModeLabel(log.billing_mode, t),
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
log.cache_read_tokens, log.cache_read_tokens,
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
<Icon name="x" size="sm" /> <Icon name="x" size="sm" />
<span>{{ t('payment.orders.cancel') }}</span> <span>{{ t('payment.orders.cancel') }}</span>
</button> </button>
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20"> <button v-if="canRequestRefund(row)" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="dollar" size="sm" /> <Icon name="dollar" size="sm" />
<span>{{ t('payment.orders.requestRefund') }}</span> <span>{{ t('payment.orders.requestRefund') }}</span>
</button> </button>
...@@ -102,6 +102,7 @@ const appStore = useAppStore() ...@@ -102,6 +102,7 @@ const appStore = useAppStore()
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
const orders = ref<PaymentOrder[]>([]) const orders = ref<PaymentOrder[]>([])
const refundEligibleProviders = ref<Set<string>>(new Set())
const currentFilter = ref('') const currentFilter = ref('')
const cancelTargetId = ref<number | null>(null) const cancelTargetId = ref<number | null>(null)
const refundTarget = ref<PaymentOrder | null>(null) const refundTarget = ref<PaymentOrder | null>(null)
...@@ -171,5 +172,18 @@ async function confirmRefund() { ...@@ -171,5 +172,18 @@ async function confirmRefund() {
} }
} }
onMounted(() => fetchOrders()) function canRequestRefund(order: PaymentOrder): boolean {
if (order.status !== 'COMPLETED') return false
if (!order.provider_instance_id) return false
return refundEligibleProviders.value.has(order.provider_instance_id)
}
async function loadRefundEligibility() {
try {
const res = await paymentAPI.getRefundEligibleProviders()
refundEligibleProviders.value = new Set(res.data.provider_instance_ids || [])
} catch { /* ignore — default to hiding refund button */ }
}
onMounted(() => { fetchOrders(); loadRefundEligibility() })
</script> </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