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

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

parents 45061102 be7551b9
......@@ -34,12 +34,16 @@
<span class="font-mono text-gray-900 dark:text-white">#{{ order?.id }}</span>
</div>
<div class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span>
<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 class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ order?.pay_amount?.toFixed(2) }}</span>
</div>
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
<span class="font-medium text-red-600 dark:text-red-400">${{ actuallyRefunded.toFixed(2) }}</span>
<span class="font-medium text-red-600 dark:text-red-400">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ actuallyRefunded.toFixed(2) }}</span>
</div>
</div>
......@@ -66,7 +70,7 @@
</div>
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }}</div>
</div>
</div>
......@@ -91,7 +95,7 @@
<div>
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">{{ order?.order_type === 'balance' ? '$' : '¥' }}</span>
<input
v-model.number="form.amount"
type="number"
......@@ -103,7 +107,7 @@
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }}
{{ t('payment.admin.maxRefundable') }}: {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ maxRefundable.toFixed(2) }}
</p>
</div>
......@@ -200,12 +204,12 @@ const actuallyRefunded = computed(() => {
const maxRefundable = computed(() => {
if (!props.order) return 0
return props.order.pay_amount - actuallyRefunded.value
return props.order.amount - actuallyRefunded.value
})
const balanceInsufficient = computed(() => {
if (props.userBalance == null || !props.order) return false
return props.userBalance < props.order.pay_amount
return props.userBalance < props.order.amount
})
watch(() => props.show, (val) => {
......
......@@ -28,17 +28,12 @@
<div class="min-w-0 flex-1">
<p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p>
<p class="text-xl font-bold text-green-600">
${{ ((stats?.total_account_cost ?? stats?.total_actual_cost) || 0).toFixed(4) }}
${{ (stats?.total_actual_cost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400" v-if="stats?.total_account_cost != null">
{{ t('usage.userBilled') }}:
<span class="text-gray-300">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</span>
· {{ t('usage.standardCost') }}:
<span class="text-gray-300">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
<p class="text-xs text-gray-400" v-else>
{{ t('usage.standardCost') }}:
<span class="line-through">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<p class="text-xs text-gray-400">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ (stats?.total_account_cost || 0).toFixed(4) }}</span>
<span> · </span>
<span>{{ t('usage.standardCost') }} ${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</p>
</div>
</div>
......
......@@ -87,13 +87,13 @@
<template #cell-billing_mode="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="getBillingModeBadgeClass(row.billing_mode)">
{{ getBillingModeLabel(row.billing_mode) }}
{{ getBillingModeLabel(row.billing_mode, t) }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求(仅按次计费时显示图片格式) -->
<div v-if="row.image_count > 0 && row.billing_mode === 'image'" class="flex items-center gap-1.5">
<div v-if="row.image_count > 0 && row.billing_mode === BILLING_MODE_IMAGE" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
......@@ -154,8 +154,8 @@
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
</template>
......@@ -279,6 +279,8 @@
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="!tooltipData?.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
......@@ -287,6 +289,12 @@
<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 === 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 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="font-medium text-white">${{ tooltipData.cache_creation_cost.toFixed(6) }}</span>
......@@ -305,10 +313,6 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
......@@ -317,10 +321,19 @@
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</div>
......@@ -338,6 +351,15 @@ import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILLING_MODE_IMAGE } from '@/utils/billingMode'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
const result = base * (row.account_rate_multiplier ?? 1)
return Number.isNaN(result) ? 0 : result
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
......@@ -391,17 +413,6 @@ const getRequestTypeBadgeClass = (row: AdminUsageLog): string => {
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 dark:text-blue-200'
if (mode === 'image') return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}
const formatUserAgent = (ua: string): string => {
......
......@@ -45,6 +45,7 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
......@@ -75,13 +76,16 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(group.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(group.cost) }}
</td>
</tr>
<!-- User breakdown sub-rows -->
<tr v-if="expandedKey === `group-${group.group_id}`">
<td colspan="5" class="p-0">
<td colspan="6" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
......
......@@ -114,6 +114,7 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
......@@ -142,12 +143,15 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(model.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(model.cost) }}
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="5" class="p-0">
<td colspan="6" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
......
......@@ -25,6 +25,9 @@
<td class="py-1 text-right text-green-600 dark:text-green-400">
${{ formatCost(user.actual_cost) }}
</td>
<td class="py-1 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(user.account_cost) }}
</td>
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
${{ formatCost(user.cost) }}
</td>
......
......@@ -823,7 +823,6 @@ onMounted(() => {
.sidebar-brand {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
transition:
max-width 0.22s ease,
......@@ -834,6 +833,7 @@ onMounted(() => {
.sidebar-brand-collapsed {
max-width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(-4px);
pointer-events: none;
......
......@@ -22,8 +22,11 @@ describe('AppSidebar custom SVG styles', () => {
describe('AppSidebar header styles', () => {
it('does not clip the version badge dropdown', () => {
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
const sidebarBrandBlockMatch = componentSource.match(/\.sidebar-brand\s*\{[\s\S]*?\n\}/)
expect(sidebarHeaderBlockMatch).not.toBeNull()
expect(sidebarBrandBlockMatch).not.toBeNull()
expect(sidebarHeaderBlockMatch?.[0]).not.toContain('@apply overflow-hidden;')
expect(sidebarBrandBlockMatch?.[0]).not.toContain('overflow: hidden;')
})
})
......@@ -12,10 +12,15 @@
<span v-if="row.user_notes" class="ml-1 text-xs text-gray-400">({{ row.user_notes }})</span>
</div>
</template>
<template #cell-amount="{ value, row }">
<template #cell-pay_amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span>
<span v-if="row.pay_amount !== value" class="ml-1 text-xs text-gray-500">(${{ row.pay_amount.toFixed(2) }})</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.fee') }} {{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
</div>
</div>
</template>
<template #cell-payment_type="{ value }">
......@@ -60,7 +65,7 @@ const columns = computed((): Column[] => {
cols.push({ key: 'user_email', label: t('payment.admin.colUser') })
}
cols.push(
{ key: 'amount', label: t('payment.orders.amount') },
{ key: 'pay_amount', label: t('payment.orders.payAmount') },
{ key: 'payment_type', label: t('payment.orders.paymentMethod') },
{ key: 'status', label: t('payment.orders.status') },
{ key: 'created_at', label: t('payment.orders.createdAt') },
......
......@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) -->
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5">
......@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled: boolean
payment_mode: string
refund_enabled: boolean
allow_user_refund: boolean
config: Record<string, string>
limits: string
}]
......@@ -258,6 +260,7 @@ const form = reactive({
enabled: true,
payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false,
allow_user_refund: false,
})
const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({})
......@@ -433,6 +436,7 @@ function handleSave() {
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
config: filteredConfig,
limits: serializeLimits(),
})
......@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.refund_enabled = false
form.allow_user_refund = false
clearConfig()
applyDefaults()
}
......@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if (provider.config) {
......
......@@ -115,7 +115,7 @@ const emit = defineEmits<{
create: []
edit: [provider: ProviderInstance]
delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled']
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]]
}>()
......
......@@ -45,7 +45,11 @@
</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>
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.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-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
......
......@@ -22,7 +22,11 @@
</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>
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.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-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
......
......@@ -46,6 +46,7 @@
<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')" />
<ToggleSwitch v-if="provider.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="provider.allow_user_refund" @toggle="emit('toggleField', 'allow_user_refund')" />
<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" />
......@@ -84,7 +85,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
toggleField: [field: 'enabled' | 'refund_enabled']
toggleField: [field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [type: string]
edit: []
delete: []
......
......@@ -21,9 +21,13 @@
<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">
<div v-if="amount > 0" 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>
<span class="font-medium text-gray-900 dark:text-white">{{ orderType === 'balance' ? '$' : '¥' }}{{ amount.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-medium text-gray-900 dark:text-white">¥{{ payAmount.toFixed(2) }}</span>
</div>
</div>
</div>
......@@ -36,7 +40,7 @@
<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>
<p class="mt-1 text-3xl font-bold text-white">¥{{ payAmount.toFixed(2) }}</p>
</div>
</div>
<!-- Stripe Payment Element -->
......@@ -75,7 +79,9 @@ const POPUP_METHODS = new Set(['alipay', 'wechat_pay'])
const props = defineProps<{
orderId: number
amount: number
clientSecret: string
orderType?: 'balance' | 'subscription'
publishableKey: string
payAmount: number
}>()
......
<template>
<label class="flex items-center gap-1.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</span>
<label class="flex flex-col items-center gap-0.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ label }}</span>
<button
type="button"
role="switch"
......
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.balanceNotify.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.balanceNotify.description') }}
</p>
</div>
<div class="px-6 py-6 space-y-6">
<!-- Enable toggle -->
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('profile.balanceNotify.enabled') }}</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="notifyEnabled" @change="handleToggle" class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:after:border-gray-600 peer-checked:bg-primary-600"></div>
</label>
</div>
<template v-if="notifyEnabled">
<!-- Custom threshold with save button -->
<div>
<label class="input-label">
{{ t('profile.balanceNotify.threshold') }}
<span class="text-xs text-gray-400 ml-2">{{ t('profile.balanceNotify.thresholdHint') }}</span>
</label>
<div class="flex items-center gap-2">
<span class="text-gray-500">$</span>
<input
v-model.number="customThreshold"
type="number"
min="0"
step="0.01"
class="input flex-1"
:placeholder="systemDefaultThreshold > 0 ? `${t('profile.balanceNotify.systemDefault')} $${systemDefaultThreshold}` : t('profile.balanceNotify.thresholdPlaceholder')"
/>
<button
@click="handleThresholdUpdate"
:disabled="savingThreshold"
class="btn btn-primary btn-sm whitespace-nowrap"
>
{{ savingThreshold ? t('common.saving') : t('common.save') }}
</button>
</div>
</div>
<!-- Email list with toggles -->
<div>
<label class="input-label">{{ t('profile.balanceNotify.extraEmails') }}</label>
<p class="mb-2 text-xs text-yellow-600 dark:text-yellow-400">{{ t('profile.balanceNotify.extraEmailsHint') }}</p>
<!-- Saved email entries -->
<div v-if="emailEntries.length > 0" class="space-y-2 mb-3">
<div v-for="(entry, idx) in emailEntries" :key="idx"
class="flex items-center justify-between px-3 py-2 bg-gray-50 dark:bg-dark-700 rounded-lg">
<div class="flex items-center gap-2 min-w-0 flex-1">
<label class="relative inline-flex items-center cursor-pointer shrink-0">
<input type="checkbox" :checked="!entry.disabled" @change="handleEmailToggle(entry)" class="sr-only peer" />
<div class="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:after:border-gray-500 peer-checked:bg-primary-600"></div>
</label>
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">{{ entry.email }}</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<template v-if="!entry.verified">
<!-- Inline verify flow for saved unverified emails -->
<template v-if="verifyingEmail === entry.email">
<input
v-model="verifyCode"
type="text"
maxlength="6"
class="w-20 rounded border border-gray-300 px-2 py-1 text-xs dark:border-dark-500 dark:bg-dark-700"
:placeholder="t('profile.balanceNotify.codePlaceholder')"
/>
<button @click="verifySavedEmail(entry.email)" :disabled="!verifyCode || verifyCode.length !== 6 || verifyingSaved" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.verify') }}
</button>
<span v-if="verifyCountdown > 0" class="text-xs text-gray-400">{{ verifyCountdown }}s</span>
<button v-else @click="sendCodeForSaved(entry.email)" :disabled="sendingSavedCode" class="text-xs text-gray-500 hover:text-gray-700">
{{ t('profile.balanceNotify.resend') }}
</button>
<button @click="verifyingEmail = ''" class="text-xs text-gray-400 hover:text-gray-600">
{{ t('common.cancel') }}
</button>
</template>
<template v-else>
<button @click="sendCodeForSaved(entry.email)" :disabled="sendingSavedCode" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.verify') }}
</button>
<span class="text-xs text-yellow-500">{{ t('profile.balanceNotify.unverified') }}</span>
</template>
</template>
<span v-else class="text-xs text-green-500">{{ t('profile.balanceNotify.verified') }}</span>
<button @click="handleRemoveEmail(entry.email)" class="text-red-500 hover:text-red-700 text-xs">
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
</div>
</div>
<!-- Pending (unverified) emails in verification flow -->
<div v-if="pendingEmails.length > 0" class="space-y-2 mb-3">
<div v-for="(pe, idx) in pendingEmails" :key="pe.email"
class="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/10 rounded-lg border border-yellow-200 dark:border-yellow-800">
<span class="flex-1 text-sm text-gray-700 dark:text-gray-300">{{ pe.email }}</span>
<div v-if="!pe.codeSent" class="flex items-center gap-1">
<button @click="sendCodeFor(idx)" :disabled="pe.sending" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.sendCode') }}
</button>
<button @click="pendingEmails.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700 ml-1">
{{ t('profile.balanceNotify.removeEmail') }}
</button>
</div>
<div v-else class="flex items-center gap-1">
<input
v-model="pe.code"
type="text"
maxlength="6"
class="w-20 rounded border border-gray-300 px-2 py-1 text-xs dark:border-dark-500 dark:bg-dark-700"
:placeholder="t('profile.balanceNotify.codePlaceholder')"
/>
<button @click="verifyPending(idx)" :disabled="!pe.code || pe.code.length !== 6 || pe.verifying" class="text-xs text-primary-600 hover:text-primary-700">
{{ t('profile.balanceNotify.verify') }}
</button>
<span v-if="pe.countdown > 0" class="text-xs text-gray-400">{{ pe.countdown }}s</span>
<button v-else @click="sendCodeFor(idx)" :disabled="pe.sending" class="text-xs text-gray-500 hover:text-gray-700">
{{ t('profile.balanceNotify.resend') }}
</button>
</div>
</div>
</div>
<!-- Add new email input (hidden when at limit) -->
<div v-if="canAddMore" class="flex gap-2">
<input
v-model="newEmail"
type="email"
class="input flex-1"
:placeholder="t('profile.balanceNotify.emailPlaceholder')"
@keyup.enter="addPendingEmail"
/>
<button
@click="addPendingEmail"
:disabled="!newEmail"
class="btn btn-secondary whitespace-nowrap"
>
{{ t('common.add') }}
</button>
</div>
<p v-else class="text-xs text-gray-400">
{{ t('profile.balanceNotify.maxEmailsReached') }}
</p>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { userAPI } from '@/api'
import { extractApiErrorMessage } from '@/utils/apiError'
import type { NotifyEmailEntry } from '@/types'
const maxTotalEmails = 3
interface PendingEmail {
email: string
codeSent: boolean
code: string
sending: boolean
verifying: boolean
countdown: number
timer: ReturnType<typeof setInterval> | null
}
const props = defineProps<{
enabled: boolean
threshold: number | null
extraEmails: NotifyEmailEntry[]
systemDefaultThreshold: number
userEmail: string
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const appStore = useAppStore()
const notifyEnabled = ref(props.enabled)
const customThreshold = ref<number | null>(props.threshold)
const emailEntries = ref<NotifyEmailEntry[]>([...props.extraEmails])
const pendingEmails = ref<PendingEmail[]>([])
const newEmail = ref('')
const savingThreshold = ref(false)
// State for verifying saved unverified emails
const verifyingEmail = ref('')
const verifyCode = ref('')
const verifyingSaved = ref(false)
const sendingSavedCode = ref(false)
const verifyCountdown = ref(0)
let verifyTimer: ReturnType<typeof setInterval> | null = null
const canAddMore = computed(() => {
return emailEntries.value.length + pendingEmails.value.length < maxTotalEmails
})
watch(() => props.enabled, (val) => { notifyEnabled.value = val })
watch(() => props.threshold, (val) => { customThreshold.value = val })
watch(() => props.extraEmails, (val) => { emailEntries.value = [...val] })
// When list is empty on mount, pre-fill the add input with user's email
onMounted(() => {
if (emailEntries.value.length === 0 && props.userEmail) {
newEmail.value = props.userEmail
}
})
onUnmounted(() => {
for (const pe of pendingEmails.value) {
if (pe.timer) clearInterval(pe.timer)
}
if (verifyTimer) clearInterval(verifyTimer)
})
const handleToggle = async () => {
try {
const updated = await userAPI.updateProfile({ balance_notify_enabled: notifyEnabled.value })
authStore.user = updated
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
notifyEnabled.value = !notifyEnabled.value
}
}
const handleThresholdUpdate = async () => {
savingThreshold.value = true
try {
const threshold = customThreshold.value && customThreshold.value > 0 ? customThreshold.value : 0
const updated = await userAPI.updateProfile({ balance_notify_threshold: threshold })
authStore.user = updated
appStore.showSuccess(t('common.saved'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
savingThreshold.value = false
}
}
async function handleEmailToggle(entry: NotifyEmailEntry) {
const newDisabled = !entry.disabled
try {
const updated = await userAPI.toggleNotifyEmail(entry.email, newDisabled)
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
function addPendingEmail() {
const email = newEmail.value.trim()
if (!email) return
// Check duplicates
const isDuplicate = emailEntries.value.some(e => e.email.toLowerCase() === email.toLowerCase())
|| pendingEmails.value.some(p => p.email.toLowerCase() === email.toLowerCase())
if (isDuplicate) {
appStore.showError(t('profile.balanceNotify.emailDuplicate'))
return
}
pendingEmails.value.push({ email, codeSent: false, code: '', sending: false, verifying: false, countdown: 0, timer: null })
newEmail.value = ''
}
async function sendCodeFor(idx: number) {
const pe = pendingEmails.value[idx]
if (!pe) return
pe.sending = true
try {
await userAPI.sendNotifyEmailCode(pe.email)
pe.codeSent = true
pe.countdown = 60
pe.timer = setInterval(() => {
pe.countdown--
if (pe.countdown <= 0 && pe.timer) {
clearInterval(pe.timer)
pe.timer = null
}
}, 1000)
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
pe.sending = false
}
}
async function verifyPending(idx: number) {
const pe = pendingEmails.value[idx]
if (!pe || !pe.code || pe.code.length !== 6) return
pe.verifying = true
try {
await userAPI.verifyNotifyEmail(pe.email, pe.code)
if (pe.timer) clearInterval(pe.timer)
pendingEmails.value.splice(idx, 1)
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
const updated = await userAPI.getProfile()
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
pe.verifying = false
}
}
const handleRemoveEmail = async (email: string) => {
try {
await userAPI.removeNotifyEmail(email)
appStore.showSuccess(t('profile.balanceNotify.removeSuccess'))
const updated = await userAPI.getProfile()
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
// Verify saved unverified emails
async function sendCodeForSaved(email: string) {
sendingSavedCode.value = true
try {
await userAPI.sendNotifyEmailCode(email)
verifyingEmail.value = email
verifyCode.value = ''
verifyCountdown.value = 60
if (verifyTimer) clearInterval(verifyTimer)
verifyTimer = setInterval(() => {
verifyCountdown.value--
if (verifyCountdown.value <= 0 && verifyTimer) {
clearInterval(verifyTimer)
verifyTimer = null
}
}, 1000)
appStore.showSuccess(t('profile.balanceNotify.codeSent'))
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
sendingSavedCode.value = false
}
}
async function verifySavedEmail(email: string) {
if (!verifyCode.value || verifyCode.value.length !== 6) return
verifyingSaved.value = true
try {
await userAPI.verifyNotifyEmail(email, verifyCode.value)
verifyingEmail.value = ''
verifyCode.value = ''
if (verifyTimer) { clearInterval(verifyTimer); verifyTimer = null }
appStore.showSuccess(t('profile.balanceNotify.verifySuccess'))
const updated = await userAPI.getProfile()
authStore.user = updated
emailEntries.value = [...updated.balance_notify_extra_emails]
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
verifyingSaved.value = false
}
}
</script>
import { reactive, ref } from 'vue'
import { adminAPI } from '@/api/admin'
import { QUOTA_THRESHOLD_TYPE_FIXED, type QuotaThresholdType } from '@/constants/account'
export const QUOTA_NOTIFY_DIMS = ['daily', 'weekly', 'total'] as const
export type QuotaNotifyDim = (typeof QUOTA_NOTIFY_DIMS)[number]
interface DimState {
enabled: boolean | null
threshold: number | null
thresholdType: QuotaThresholdType | null
}
export function useQuotaNotifyState() {
const globalEnabled = ref(false)
const state = reactive<Record<QuotaNotifyDim, DimState>>({
daily: { enabled: null, threshold: null, thresholdType: null },
weekly: { enabled: null, threshold: null, thresholdType: null },
total: { enabled: null, threshold: null, thresholdType: null },
})
function loadGlobalState() {
adminAPI.settings
.getSettings()
.then((settings) => {
globalEnabled.value = settings.account_quota_notify_enabled === true
})
.catch(() => {
globalEnabled.value = false
})
}
function loadFromExtra(extra: Record<string, unknown> | null | undefined) {
for (const d of QUOTA_NOTIFY_DIMS) {
state[d].enabled = (extra?.[`quota_notify_${d}_enabled`] as boolean) ?? null
state[d].threshold = (extra?.[`quota_notify_${d}_threshold`] as number) ?? null
state[d].thresholdType = (extra?.[`quota_notify_${d}_threshold_type`] as QuotaThresholdType) ?? null
}
}
function writeToExtra(extra: Record<string, unknown>, mode: 'create' | 'update') {
for (const d of QUOTA_NOTIFY_DIMS) {
const s = state[d]
if (s.enabled) {
extra[`quota_notify_${d}_enabled`] = true
if (s.threshold != null) {
extra[`quota_notify_${d}_threshold`] = s.threshold
} else if (mode === 'update') {
delete extra[`quota_notify_${d}_threshold`]
}
extra[`quota_notify_${d}_threshold_type`] = s.thresholdType || QUOTA_THRESHOLD_TYPE_FIXED
} else if (mode === 'update') {
delete extra[`quota_notify_${d}_enabled`]
delete extra[`quota_notify_${d}_threshold`]
delete extra[`quota_notify_${d}_threshold_type`]
}
}
}
function reset() {
for (const d of QUOTA_NOTIFY_DIMS) {
state[d].enabled = null
state[d].threshold = null
state[d].thresholdType = null
}
}
return { globalEnabled, state, loadGlobalState, loadFromExtra, writeToExtra, reset }
}
......@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
replaceSelectedSet(next)
}
const batchUpdate = (updater: (draft: Set<number>) => void) => {
const draft = new Set(selectedSet.value)
updater(draft)
replaceSelectedSet(draft)
}
const selectVisible = () => {
toggleVisible(true)
}
......@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear,
removeMany,
toggleVisible,
selectVisible
selectVisible,
batchUpdate
}
}
/** WebSearch emulation mode values (must match backend WebSearchMode* constants in account.go) */
export const WEB_SEARCH_MODE_DEFAULT = 'default' as const
export const WEB_SEARCH_MODE_ENABLED = 'enabled' as const
export const WEB_SEARCH_MODE_DISABLED = 'disabled' as const
export type WebSearchMode = typeof WEB_SEARCH_MODE_DEFAULT | typeof WEB_SEARCH_MODE_ENABLED | typeof WEB_SEARCH_MODE_DISABLED
/** Quota notification threshold type values (must match thresholdType* constants in balance_notify_service.go) */
export const QUOTA_THRESHOLD_TYPE_FIXED = 'fixed' as const
export const QUOTA_THRESHOLD_TYPE_PERCENTAGE = 'percentage' as const
export type QuotaThresholdType = typeof QUOTA_THRESHOLD_TYPE_FIXED | typeof QUOTA_THRESHOLD_TYPE_PERCENTAGE
/** Quota reset mode values */
export const QUOTA_RESET_MODE_ROLLING = 'rolling' as const
export const QUOTA_RESET_MODE_FIXED = 'fixed' as const
export type QuotaResetMode = typeof QUOTA_RESET_MODE_ROLLING | typeof QUOTA_RESET_MODE_FIXED
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