mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6655 from ouchihao/codex/issue-275-account-expiry-presets
feat(accounts): add month and year expiry presets
This commit is contained in:
@@ -2961,6 +2961,14 @@
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
<input v-model="expiresAtInput" type="datetime-local" class="input" />
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(1)">
|
||||
{{ t('payment.oneMonth') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(12)">
|
||||
{{ t('payment.oneYear') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="input-hint">
|
||||
{{ t('admin.accounts.expiresAtHint') }}
|
||||
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
|
||||
@@ -3867,6 +3875,7 @@ import {
|
||||
parseDateTimeLocalInput
|
||||
} from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry'
|
||||
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
|
||||
@@ -1617,6 +1617,14 @@
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<label class="input-label">{{ t('admin.accounts.expiresAt') }}</label>
|
||||
<input v-model="expiresAtInput" type="datetime-local" class="input" />
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(1)">
|
||||
{{ t('payment.oneMonth') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="form.expires_at = getAccountExpiryTimestamp(12)">
|
||||
{{ t('payment.oneYear') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="input-hint">
|
||||
{{ t('admin.accounts.expiresAtHint') }}
|
||||
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
|
||||
@@ -2963,6 +2971,7 @@ import {
|
||||
parseDateTimeLocalInput
|
||||
} from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry'
|
||||
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
|
||||
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
createAccountMock,
|
||||
@@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
createOpenAICodexPATMock.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('sets month and year expiry presets without submitting the account form', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-01-31T12:34:00'))
|
||||
const wrapper = mountModal()
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await selectButtonByText(wrapper, 'API Key')
|
||||
await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account')
|
||||
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
|
||||
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
|
||||
|
||||
for (const [label, expected] of [
|
||||
['payment.oneMonth', '2026-02-28T12:34'],
|
||||
['payment.oneYear', '2027-01-31T12:34'],
|
||||
]) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)!
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
await button.trigger('click')
|
||||
expect(input.element.value).toBe(expected)
|
||||
expect(createAccountMock).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('allows a manually entered expiry to override a preset before account creation', async () => {
|
||||
const wrapper = mountModal()
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await selectButtonByText(wrapper, 'API Key')
|
||||
await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account')
|
||||
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
|
||||
await selectButtonByText(wrapper, 'payment.oneMonth')
|
||||
await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20')
|
||||
|
||||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides only the redundant account toggle when every selected group enables tier pricing', async () => {
|
||||
authIsSimpleMode.value = false
|
||||
const wrapper = mountModal([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
@@ -328,6 +328,50 @@ describe('EditAccountModal', () => {
|
||||
authIsSimpleMode.value = true
|
||||
})
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('sets expiry presets from now instead of extending the saved expiry', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2028-02-29T12:34:00'))
|
||||
const account = buildAccount()
|
||||
account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000
|
||||
updateAccountMock.mockReset().mockResolvedValue(account)
|
||||
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||
const wrapper = mountModal(account)
|
||||
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
|
||||
|
||||
for (const [label, expected] of [
|
||||
['payment.oneMonth', '2028-03-29T12:34'],
|
||||
['payment.oneYear', '2029-02-28T12:34'],
|
||||
]) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)!
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
await button.trigger('click')
|
||||
expect(input.element.value).toBe(expected)
|
||||
expect(updateAccountMock).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('can clear a selected expiry preset before saving the account', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset().mockResolvedValue(account)
|
||||
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||
const wrapper = mountModal(account)
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')!
|
||||
await button.trigger('click')
|
||||
const input = wrapper.get<HTMLInputElement>('input[type="datetime-local"]')
|
||||
expect(input.element.value).not.toBe('')
|
||||
await input.setValue('')
|
||||
|
||||
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||
expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reopening the same account rehydrates the OpenAI whitelist from props', async () => {
|
||||
const account = buildAccount()
|
||||
updateAccountMock.mockReset()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAccountExpiryTimestamp } from '../accountExpiry'
|
||||
|
||||
describe('getAccountExpiryTimestamp', () => {
|
||||
it.each([
|
||||
['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'],
|
||||
['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'],
|
||||
['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'],
|
||||
['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'],
|
||||
// With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date.
|
||||
['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'],
|
||||
])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => {
|
||||
const now = new Date(input)
|
||||
const originalTimestamp = now.getTime()
|
||||
|
||||
expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000)
|
||||
expect(now.getTime()).toBe(originalTimestamp)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Set an expiry from local calendar months, clamping month-end dates. */
|
||||
export function getAccountExpiryTimestamp(months: number, now = new Date()): number {
|
||||
const expiry = new Date(now.getTime())
|
||||
const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate()
|
||||
expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay))
|
||||
return Math.floor(expiry.getTime() / 1000)
|
||||
}
|
||||
Reference in New Issue
Block a user