mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
show reset credit expiration
This commit is contained in:
@@ -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>
|
||||
|
||||
+75
-3
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -3488,6 +3488,11 @@ export default {
|
||||
resetTooltipNeedQuery: '先点击「次数」加载剩余重置次数',
|
||||
resetTooltipNoCredits: '没有可用的重置次数',
|
||||
resetTooltipShadow: 'Spark 影子账号不能重置次数;请在母账号上重置',
|
||||
expiresAt: '到期 {time}',
|
||||
expiresAtFull: '重置次数到期时间: {time}',
|
||||
expandExpirations: '展开其余 {count} 张重置次数到期时间',
|
||||
collapseExpirations: '收起重置次数到期时间',
|
||||
expirationDetails: '重置次数到期明细',
|
||||
noCreditsAvailable: '没有可用的重置次数',
|
||||
resetSuccess: '已重置 {windows} 个窗口',
|
||||
confirmTitle: '确认重置周限',
|
||||
|
||||
Reference in New Issue
Block a user