mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
Merge pull request #4385 from yardbirds0/feat/upstream-billing-probe
feat: 增加上游 Sub2API 计费倍率探测与账号展示
This commit is contained in:
@@ -1592,6 +1592,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
|
||||
class="flex items-center justify-between gap-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.upstreamBilling.autoProbe') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.upstreamBilling.autoProbeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
v-model="upstreamBillingAutoProbeEnabled"
|
||||
data-testid="upstream-billing-auto-probe"
|
||||
:aria-label="t('admin.accounts.upstreamBilling.autoProbe')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Anthropic API Key 自动透传开关 -->
|
||||
<div
|
||||
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
|
||||
@@ -2560,6 +2577,7 @@ import type {
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import ProxyAdBanner from '@/components/common/ProxyAdBanner.vue'
|
||||
@@ -2729,6 +2747,7 @@ const autoPause5hThreshold = ref<number | null>(null)
|
||||
const autoPause7dThreshold = ref<number | null>(null)
|
||||
const autoPause5hDisabled = ref(false)
|
||||
const autoPause7dDisabled = ref(false)
|
||||
const upstreamBillingAutoProbeEnabled = ref(false)
|
||||
const mixedScheduling = ref(false) // For antigravity accounts: enable mixed scheduling
|
||||
const allowOverages = ref(false) // For antigravity accounts: enable AI Credits overages
|
||||
const antigravityProjectId = ref('')
|
||||
@@ -3210,6 +3229,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
autoPause7dThreshold.value = typeof extra?.auto_pause_7d_threshold === 'number' ? extra.auto_pause_7d_threshold * 100 : null
|
||||
autoPause5hDisabled.value = extra?.auto_pause_5h_disabled === true
|
||||
autoPause7dDisabled.value = extra?.auto_pause_7d_disabled === true
|
||||
upstreamBillingAutoProbeEnabled.value = extra?.upstream_billing_probe_enabled === true
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
@@ -4463,6 +4483,7 @@ const handleSubmit = async () => {
|
||||
} else {
|
||||
newExtra.openai_responses_mode = openAIResponsesMode.value
|
||||
}
|
||||
newExtra.upstream_billing_probe_enabled = upstreamBillingAutoProbeEnabled.value
|
||||
}
|
||||
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {
|
||||
newExtra.auto_pause_5h_threshold = autoPause5hThreshold.value / 100
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div v-if="eligible" class="flex h-6 min-w-[7rem] items-center gap-1">
|
||||
<HelpTooltip class="-ml-1" width-class="w-64" data-testid="upstream-billing-details">
|
||||
<template #trigger>
|
||||
<span
|
||||
class="cursor-help border-b border-dotted border-gray-300 font-mono text-sm font-medium text-gray-800 dark:border-gray-600 dark:text-gray-200"
|
||||
data-testid="upstream-billing-rate"
|
||||
>
|
||||
{{ effectiveRate }}
|
||||
</span>
|
||||
</template>
|
||||
<div v-if="data" class="space-y-1">
|
||||
<p>{{ t('admin.accounts.upstreamBilling.groupRate', { value: data.group_rate_multiplier }) }}</p>
|
||||
<p v-if="data.user_rate_multiplier != null">
|
||||
{{ t('admin.accounts.upstreamBilling.userRate', { value: data.user_rate_multiplier }) }}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
data.peak_rate_enabled
|
||||
? t('admin.accounts.upstreamBilling.peakRate', {
|
||||
start: data.peak_start,
|
||||
end: data.peak_end,
|
||||
value: data.peak_rate_multiplier,
|
||||
timezone: data.timezone
|
||||
})
|
||||
: t('admin.accounts.upstreamBilling.noPeakRate')
|
||||
}}
|
||||
</p>
|
||||
<p>{{ t('admin.accounts.upstreamBilling.effectiveRate', { value: currentEffectiveRate ?? '-' }) }}</p>
|
||||
<p>{{ t('admin.accounts.upstreamBilling.updatedAt', { value: formatDate(snapshot?.received_at) }) }}</p>
|
||||
</div>
|
||||
<span v-else>{{ statusLabel }}</span>
|
||||
</HelpTooltip>
|
||||
<span v-if="statusLabel" :class="statusClass" class="whitespace-nowrap text-[10px] font-medium">
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
:disabled="probing"
|
||||
:aria-label="t('admin.accounts.upstreamBilling.manualProbe')"
|
||||
:title="t('admin.accounts.upstreamBilling.manualProbe')"
|
||||
data-testid="upstream-billing-probe"
|
||||
@click="$emit('probe')"
|
||||
>
|
||||
<Icon name="refresh" size="xs" :class="{ 'animate-spin': probing }" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { Account, UpstreamBillingProbeSnapshot } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
account: Account
|
||||
intervalMinutes: number
|
||||
now: number
|
||||
probing?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(event: 'probe'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const eligible = computed(() => props.account.platform === 'openai' && props.account.type === 'apikey')
|
||||
const snapshot = computed<UpstreamBillingProbeSnapshot | undefined>(() => props.account.extra?.upstream_billing_probe)
|
||||
const data = computed(() => snapshot.value?.data)
|
||||
const receivedAt = computed(() => typeof snapshot.value?.received_at === 'string' ? Date.parse(snapshot.value.received_at) : Number.NaN)
|
||||
const freshUntil = computed(() => {
|
||||
if (typeof snapshot.value?.fresh_until === 'string') return Date.parse(snapshot.value.fresh_until)
|
||||
if (snapshot.value?.status !== 'ok' || typeof snapshot.value.next_probe_at !== 'string') return Number.NaN
|
||||
const nextProbeAt = Date.parse(snapshot.value.next_probe_at)
|
||||
return Number.isFinite(nextProbeAt) && nextProbeAt > receivedAt.value
|
||||
? receivedAt.value + 2 * (nextProbeAt - receivedAt.value)
|
||||
: Number.NaN
|
||||
})
|
||||
const validTimestamps = computed(() => {
|
||||
if (!Number.isFinite(receivedAt.value) || receivedAt.value > props.now) return false
|
||||
return Number.isFinite(freshUntil.value) && freshUntil.value > receivedAt.value
|
||||
})
|
||||
const stale = computed(() => {
|
||||
if (!snapshot.value) return false
|
||||
if (!Number.isFinite(receivedAt.value)) return snapshot.value.status === 'ok'
|
||||
if (!validTimestamps.value) return true
|
||||
return props.now > freshUntil.value
|
||||
})
|
||||
const parseMinute = (value?: string) => {
|
||||
if (typeof value !== 'string') return null
|
||||
const match = /^(\d{2}):(\d{2})$/.exec(value)
|
||||
if (!match) return null
|
||||
const hour = Number(match[1])
|
||||
const minute = Number(match[2])
|
||||
return hour < 24 && minute < 60 ? hour * 60 + minute : null
|
||||
}
|
||||
const minuteInTimeZone = (timestamp: number, timeZone?: string) => {
|
||||
if (!timeZone) return null
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23'
|
||||
}).formatToParts(new Date(timestamp))
|
||||
const hour = Number(parts.find(part => part.type === 'hour')?.value)
|
||||
const minute = Number(parts.find(part => part.type === 'minute')?.value)
|
||||
return Number.isInteger(hour) && Number.isInteger(minute) ? hour * 60 + minute : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const currentEffectiveRate = computed(() => {
|
||||
const billing = data.value
|
||||
if (!billing) return null
|
||||
if (billing.billing_scope !== 'token') return null
|
||||
const base = billing.resolved_rate_multiplier
|
||||
if (typeof base !== 'number' || !Number.isFinite(base) || base < 0) return null
|
||||
if (typeof billing.peak_rate_enabled !== 'boolean') return null
|
||||
if (!billing.peak_rate_enabled) return base
|
||||
const start = parseMinute(billing.peak_start)
|
||||
const end = parseMinute(billing.peak_end)
|
||||
const minute = minuteInTimeZone(props.now, billing.timezone)
|
||||
const peak = billing.peak_rate_multiplier
|
||||
if (start == null || end == null || minute == null || start >= end || typeof peak !== 'number' || !Number.isFinite(peak) || peak < 0) return null
|
||||
const value = minute >= start && minute < end ? base * peak : base
|
||||
return Number.isFinite(value) ? value : null
|
||||
})
|
||||
const effectiveRate = computed(() => {
|
||||
if (!validTimestamps.value || stale.value || !['ok', 'failed'].includes(snapshot.value?.status ?? '')) return '-'
|
||||
const value = currentEffectiveRate.value
|
||||
return value == null ? '-' : `${Number(value.toPrecision(12))}x`
|
||||
})
|
||||
const statusLabel = computed(() => {
|
||||
if (!snapshot.value) return t('admin.accounts.upstreamBilling.notProbed')
|
||||
if (snapshot.value.status === 'unsupported') return t('admin.accounts.upstreamBilling.unsupported')
|
||||
if (stale.value) return t('admin.accounts.upstreamBilling.stale')
|
||||
if (snapshot.value.status === 'failed') return t('admin.accounts.upstreamBilling.failed')
|
||||
return ''
|
||||
})
|
||||
const statusClass = computed(() => {
|
||||
if (!snapshot.value) return 'text-gray-400 dark:text-gray-500'
|
||||
if (snapshot.value.status === 'unsupported') return 'text-gray-500 dark:text-gray-400'
|
||||
if (stale.value) return 'text-amber-600 dark:text-amber-400'
|
||||
if (snapshot.value.status === 'failed') return 'text-red-600 dark:text-red-400'
|
||||
return ''
|
||||
})
|
||||
const formatDate = (value?: string) => value
|
||||
? new Date(value).toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: '-'
|
||||
</script>
|
||||
@@ -588,6 +588,24 @@ describe('EditAccountModal', () => {
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(false)
|
||||
})
|
||||
|
||||
it('submits the account upstream billing auto-probe setting', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockReset()
|
||||
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||
updateAccountMock.mockResolvedValue(account)
|
||||
|
||||
const wrapper = mountModal(account)
|
||||
const toggle = wrapper.get('[data-testid="upstream-billing-auto-probe"]')
|
||||
expect(toggle.attributes('aria-checked')).toBe('false')
|
||||
|
||||
await toggle.trigger('click')
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
|
||||
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_billing_probe_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('clears OpenAI APIKey Responses override when set back to auto', async () => {
|
||||
const account = buildAccount()
|
||||
account.extra = {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UpstreamBillingRateCell from '../UpstreamBillingRateCell.vue'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const makeAccount = (overrides: Partial<Account> = {}): Account => ({
|
||||
id: 1,
|
||||
name: 'upstream',
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
proxy_id: null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
last_used_at: null,
|
||||
expires_at: null,
|
||||
auto_pause_on_expired: false,
|
||||
created_at: '2026-07-13T00:00:00Z',
|
||||
updated_at: '2026-07-13T00:00:00Z',
|
||||
schedulable: true,
|
||||
rate_limited_at: null,
|
||||
rate_limit_reset_at: null,
|
||||
overload_until: null,
|
||||
temp_unschedulable_until: null,
|
||||
temp_unschedulable_reason: null,
|
||||
session_window_start: null,
|
||||
session_window_end: null,
|
||||
session_window_status: null,
|
||||
...overrides
|
||||
})
|
||||
|
||||
const billingData = {
|
||||
object: 'sub2api.key_billing' as const,
|
||||
schema_version: 1 as const,
|
||||
billing_scope: 'token' as const,
|
||||
group_rate_multiplier: 0.8,
|
||||
resolved_rate_multiplier: 0.6,
|
||||
peak_rate_enabled: true,
|
||||
peak_start: '09:00',
|
||||
peak_end: '18:00',
|
||||
peak_rate_multiplier: 1.5,
|
||||
applied_peak_multiplier: 1.5,
|
||||
effective_rate_multiplier: 0.9,
|
||||
timezone: 'Asia/Shanghai',
|
||||
observed_at: '2026-07-13T00:00:00Z'
|
||||
}
|
||||
|
||||
describe('UpstreamBillingRateCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-07-13T00:30:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('recomputes the current effective rate and keeps the icon-only probe action', async () => {
|
||||
const wrapper = mount(UpstreamBillingRateCell, {
|
||||
props: {
|
||||
account: makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe_enabled: true,
|
||||
upstream_billing_probe: {
|
||||
status: 'ok',
|
||||
data: billingData,
|
||||
received_at: '2026-07-13T00:00:00Z',
|
||||
fresh_until: '2026-07-14T00:00:00Z',
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T00:30:00Z'
|
||||
}
|
||||
}
|
||||
}),
|
||||
intervalMinutes: 30,
|
||||
now: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.9x')
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T10:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.latest')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-probe"]').text()).toBe('')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-probe"]').attributes('aria-label')).toBe(
|
||||
'admin.accounts.upstreamBilling.manualProbe'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses retained failed data only while it is still fresh', async () => {
|
||||
const account = makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe: {
|
||||
status: 'ok',
|
||||
data: billingData,
|
||||
received_at: '2026-07-12T22:00:00Z',
|
||||
fresh_until: '2026-07-12T23:00:00Z',
|
||||
last_attempt_at: '2026-07-12T22:00:00Z',
|
||||
next_probe_at: '2026-07-12T22:30:00Z'
|
||||
}
|
||||
}
|
||||
})
|
||||
const wrapper = mount(UpstreamBillingRateCell, { props: { account, intervalMinutes: 30, now: Date.now() } })
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
|
||||
await wrapper.setProps({
|
||||
account: makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe: {
|
||||
status: 'failed',
|
||||
data: billingData,
|
||||
received_at: '2026-07-13T00:00:00Z',
|
||||
fresh_until: '2026-07-13T01:00:00Z',
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T01:00:00Z',
|
||||
last_error: 'http_error'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('0.6x')
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed')
|
||||
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00Z') })
|
||||
expect(wrapper.text()).toContain('0.9x')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale')
|
||||
|
||||
await wrapper.setProps({ now: Date.parse('2026-07-13T01:00:00.001Z') })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
|
||||
|
||||
await wrapper.setProps({
|
||||
now: Date.now(),
|
||||
account: makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe: {
|
||||
status: 'failed',
|
||||
data: billingData,
|
||||
received_at: '2026-07-12T22:00:00Z',
|
||||
fresh_until: '2026-07-12T23:00:00Z',
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T01:00:00Z',
|
||||
last_error: 'http_error'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.stale')
|
||||
})
|
||||
|
||||
it('emits manual probe commands only for eligible accounts', async () => {
|
||||
const wrapper = mount(UpstreamBillingRateCell, {
|
||||
props: { account: makeAccount(), intervalMinutes: 30, now: Date.now() }
|
||||
})
|
||||
await wrapper.get('[data-testid="upstream-billing-probe"]').trigger('click')
|
||||
expect(wrapper.emitted('probe')).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ account: makeAccount({ type: 'oauth' }) })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
expect(wrapper.text()).toBe('-')
|
||||
})
|
||||
|
||||
it('fails neutral for malformed data and timestamps', async () => {
|
||||
const malformedAccount = (
|
||||
dataOverrides: Partial<typeof billingData> = {},
|
||||
snapshotOverrides: Record<string, unknown> = {}
|
||||
) => makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe: {
|
||||
status: 'ok',
|
||||
data: { ...billingData, ...dataOverrides },
|
||||
received_at: '2026-07-13T00:00:00Z',
|
||||
fresh_until: '2026-07-13T01:00:00Z',
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T01:00:00Z',
|
||||
...snapshotOverrides
|
||||
}
|
||||
}
|
||||
})
|
||||
const wrapper = mount(UpstreamBillingRateCell, {
|
||||
props: {
|
||||
account: malformedAccount({
|
||||
resolved_rate_multiplier: -1,
|
||||
peak_rate_enabled: false,
|
||||
effective_rate_multiplier: -1
|
||||
}),
|
||||
intervalMinutes: 30,
|
||||
now: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
await wrapper.setProps({ account: malformedAccount({ billing_scope: 'request' as 'token' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { received_at: 'not-a-time' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { received_at: '2026-07-13T00:31:00Z' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
await wrapper.setProps({ account: malformedAccount({}, { fresh_until: '2026-07-12T23:59:00Z' }) })
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
|
||||
await wrapper.setProps({
|
||||
account: makeAccount({
|
||||
extra: {
|
||||
upstream_billing_probe: {
|
||||
status: 'failed',
|
||||
last_attempt_at: '2026-07-13T00:00:00Z',
|
||||
next_probe_at: '2026-07-13T01:00:00Z',
|
||||
last_error: 'network_error'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(wrapper.get('[data-testid="upstream-billing-rate"]').text()).toBe('-')
|
||||
expect(wrapper.text()).toContain('admin.accounts.upstreamBilling.failed')
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.upstreamBilling.stale')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@
|
||||
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
|
||||
<button @click="$emit('reset-status')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.resetStatus') }}</button>
|
||||
<button @click="$emit('refresh-token')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.refreshToken') }}</button>
|
||||
<button @click="$emit('probe-upstream-billing')" class="btn btn-secondary btn-sm">{{ t('admin.accounts.bulkActions.probeUpstreamBilling') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', true)" class="btn btn-success btn-sm">{{ t('admin.accounts.bulkActions.enableScheduling') }}</button>
|
||||
<button @click="$emit('toggle-schedulable', false)" class="btn btn-warning btn-sm">{{ t('admin.accounts.bulkActions.disableScheduling') }}</button>
|
||||
<button @click="$emit('edit-selected')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
|
||||
@@ -41,5 +42,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
defineProps(['selectedIds']); defineEmits(['delete', 'edit-selected', 'edit-filtered', 'clear', 'select-page', 'toggle-schedulable', 'reset-status', 'refresh-token']); const { t } = useI18n()
|
||||
|
||||
defineProps<{ selectedIds: number[] }>()
|
||||
defineEmits([
|
||||
'delete',
|
||||
'edit-selected',
|
||||
'edit-filtered',
|
||||
'clear',
|
||||
'select-page',
|
||||
'toggle-schedulable',
|
||||
'reset-status',
|
||||
'refresh-token',
|
||||
'probe-upstream-billing'
|
||||
])
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user