show reset credit expiration

This commit is contained in:
infinityf4p
2026-07-02 13:27:58 +08:00
parent 7dc7cfce1d
commit dfb36e45f1
7 changed files with 442 additions and 4 deletions
+5
View File
@@ -734,8 +734,13 @@ export interface OpenAIAdditionalRateLimit {
rate_limit?: OpenAIRateLimit | null
}
export interface OpenAIRateLimitResetCreditDetail {
expires_at?: string
}
export interface OpenAIRateLimitResetCredits {
available_count: number
credits?: OpenAIRateLimitResetCreditDetail[]
}
export interface OpenAIQuotaUsage {
@@ -63,6 +63,46 @@
</button>
</div>
<div v-if="primaryResetCreditExpiry" class="space-y-1">
<div class="flex flex-wrap items-center gap-1">
<span
class="inline-flex max-w-full items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] leading-4 text-gray-600 tabular-nums dark:bg-gray-800 dark:text-gray-300"
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'full') })"
>
{{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'short') }) }}
</span>
<button
v-if="hiddenResetCreditCount > 0"
type="button"
data-testid="reset-credit-expiry-toggle"
class="inline-flex items-center rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium leading-4 text-gray-600 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
:aria-expanded="showResetCreditDetails"
:aria-label="resetCreditDetailsToggleLabel"
:title="resetCreditDetailsTitle"
@click="toggleResetCreditDetails"
>
+{{ hiddenResetCreditCount }}
</button>
</div>
<div
v-if="showResetCreditDetails && resetCreditExpirations.length > 1"
data-testid="reset-credit-expiry-details"
class="inline-grid max-w-full gap-0.5 rounded border border-gray-200 bg-white px-1.5 py-1 text-[10px] leading-4 text-gray-600 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300"
>
<span class="sr-only">{{ t('admin.accounts.openaiQuotaReset.expirationDetails') }}</span>
<span
v-for="(expiresAt, index) in resetCreditExpirations"
:key="`${expiresAt}-${index}`"
class="flex min-w-0 items-center gap-1 tabular-nums"
:title="t('admin.accounts.openaiQuotaReset.expiresAtFull', { time: formatResetCreditExpiry(expiresAt, 'full') })"
>
<span class="h-1 w-1 shrink-0 rounded-full bg-gray-400 dark:bg-gray-500" />
<span class="truncate">{{ formatResetCreditExpiry(expiresAt, 'short') }}</span>
</span>
</div>
</div>
<!-- Error / success feedback -->
<div
v-if="error"
@@ -118,14 +158,36 @@ const error = ref<string | null>(null)
const data = ref<OpenAIQuotaUsage | null>(null)
const resetMessage = ref<string | null>(null)
const showResetConfirm = ref(false)
const showResetCreditDetails = ref(false)
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
const isShadow = computed(() => props.account.parent_account_id != null)
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
const resetCreditExpirations = computed(() =>
(data.value?.rate_limit_reset_credits?.credits ?? [])
.map((credit) => credit.expires_at?.trim() ?? '')
.filter((expiresAt) => expiresAt.length > 0)
.sort(compareResetCreditExpiry)
)
const primaryResetCreditExpiry = computed(() => resetCreditExpirations.value[0] ?? '')
const hiddenResetCreditCount = computed(() => Math.max(resetCreditExpirations.value.length - 1, 0))
const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value)
const resetCreditDetailsTitle = computed(() =>
resetCreditExpirations.value
.map((expiresAt) => formatResetCreditExpiry(expiresAt, 'full'))
.join('\n')
)
const resetCreditDetailsToggleLabel = computed(() => {
if (showResetCreditDetails.value) {
return t('admin.accounts.openaiQuotaReset.collapseExpirations')
}
return t('admin.accounts.openaiQuotaReset.expandExpirations', { count: hiddenResetCreditCount.value })
})
const resetButtonTitle = computed(() => {
if (isShadow.value) return t('admin.accounts.openaiQuotaReset.resetTooltipShadow')
if (!data.value) return t('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
@@ -145,6 +207,34 @@ const truncatedError = computed(() => {
return error.value.length > 80 ? `${error.value.slice(0, 80)}…` : error.value
})
const getResetCreditExpiryTime = (value: string): number => {
const time = new Date(value).getTime()
return Number.isNaN(time) ? Number.POSITIVE_INFINITY : time
}
const compareResetCreditExpiry = (a: string, b: string): number => {
const diff = getResetCreditExpiryTime(a) - getResetCreditExpiryTime(b)
if (diff !== 0) return diff
return a.localeCompare(b)
}
const formatResetCreditExpiry = (value: string, style: 'short' | 'full'): string => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const options: Intl.DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}
if (style === 'full') {
options.year = 'numeric'
}
return new Intl.DateTimeFormat(undefined, options).format(date)
}
const extractErrorMessage = (e: unknown): string => {
// The project's axios response interceptor (api/client.ts) flattens server
// errors into { status, code, message, reason, ... } and re-rejects them, so
@@ -165,11 +255,17 @@ const extractErrorMessage = (e: unknown): string => {
)
}
const toggleResetCreditDetails = () => {
if (hiddenResetCreditCount.value <= 0) return
showResetCreditDetails.value = !showResetCreditDetails.value
}
const handleQuery = async () => {
if (loading.value) return
loading.value = true
error.value = null
resetMessage.value = null
showResetCreditDetails.value = false
try {
data.value = await queryOpenAIQuota(props.account.id)
} catch (e) {
@@ -224,6 +320,16 @@ watch(
loading.value = false
resetting.value = false
showResetConfirm.value = false
showResetCreditDetails.value = false
}
)
watch(
resetCreditExpirations,
() => {
if (hiddenResetCreditCount.value <= 0) {
showResetCreditDetails.value = false
}
}
)
</script>
@@ -1,13 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
import type { Account } from '@/types'
import { queryOpenAIQuota } from '@/api/admin/accounts'
vi.mock('@/api/admin/accounts', () => ({
queryOpenAIQuota: vi.fn(),
resetOpenAIQuota: vi.fn(),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params?.time ? `${key}:${params.time}` : params?.count ? `${key}:${params.count}` : key,
}),
}
})
@@ -44,6 +53,10 @@ function makeAccount(overrides: Partial<Account>): Account {
const resetButton = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('button')[1]
beforeEach(() => {
vi.mocked(queryOpenAIQuota).mockReset()
})
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
it('影子账号(parent_account_id 非空)的 reset 按钮被禁用且提示在母账号重置', () => {
const account = makeAccount({ parent_account_id: 100 })
@@ -64,4 +77,63 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
expect(btn.attributes('title')).toBe('admin.accounts.openaiQuotaReset.resetTooltipNeedQuery')
wrapper.unmount()
})
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 3,
credits: [
{ expires_at: '2026-07-05T04:05:06Z' },
{ expires_at: '2026-07-03T04:05:06Z' },
{ expires_at: 'not-a-date' },
],
},
fetched_at: 1770000000,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
expect(wrapper.text()).toContain('+2')
expect(wrapper.text()).not.toContain('not-a-date')
const toggle = wrapper.find('[data-testid="reset-credit-expiry-toggle"]')
expect(toggle.exists()).toBe(true)
expect(toggle.attributes('aria-expanded')).toBe('false')
await toggle.trigger('click')
expect(toggle.attributes('aria-expanded')).toBe('true')
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(true)
expect(wrapper.text()).toContain('not-a-date')
expect(wrapper.text()).not.toContain('undefined')
wrapper.unmount()
})
it('只有一张重置卡时不显示展开按钮', async () => {
vi.mocked(queryOpenAIQuota).mockResolvedValue({
rate_limit_reset_credits: {
available_count: 1,
credits: [
{ expires_at: '2026-07-03T04:05:06Z' },
],
},
fetched_at: 1770000000,
})
const account = makeAccount({ parent_account_id: null })
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
await wrapper.findAll('button')[0].trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="reset-credit-expiry-toggle"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="reset-credit-expiry-details"]').exists()).toBe(false)
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
wrapper.unmount()
})
})
+5
View File
@@ -4251,6 +4251,11 @@ export default {
resetTooltipNeedQuery: 'Click Credits first to load the available count',
resetTooltipNoCredits: 'No reset credits available',
resetTooltipShadow: 'Spark shadow accounts cannot reset credits; reset on the parent account',
expiresAt: 'Expires {time}',
expiresAtFull: 'Reset credit expires at {time}',
expandExpirations: 'Expand the other {count} reset credit expiration(s)',
collapseExpirations: 'Collapse reset credit expirations',
expirationDetails: 'Reset credit expiration details',
noCreditsAvailable: 'No reset credits available',
resetSuccess: 'Reset {windows} window(s)',
confirmTitle: 'Confirm Weekly Limit Reset',
+5
View File
@@ -3488,6 +3488,11 @@ export default {
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
resetTooltipNoCredits: '没有可用的重置次数',
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
expiresAt: '到期 {time}',
expiresAtFull: '重置次数到期时间: {time}',
expandExpirations: '展开其余 {count} 张重置次数到期时间',
collapseExpirations: '收起重置次数到期时间',
expirationDetails: '重置次数到期明细',
noCreditsAvailable: '没有可用的重置次数',
resetSuccess: '已重置 {windows} 个窗口',
confirmTitle: '确认重置周限',