Merge pull request #6395 from lylelllll/fix/account-expiry-timezone

fix(frontend): clarify local timezone for expiry inputs
This commit is contained in:
Wesley Liddick
2026-08-31 11:18:55 +08:00
committed by GitHub
9 changed files with 143 additions and 12 deletions
@@ -2955,7 +2955,10 @@
<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" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
<p class="input-hint">
{{ t('admin.accounts.expiresAtHint') }}
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
</p>
</div>
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
@@ -3820,7 +3823,11 @@ import {
type CnNativeApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import {
formatDateTimeLocalInput,
getBrowserTimeZone,
parseDateTimeLocalInput
} from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
import {
@@ -3852,6 +3859,7 @@ interface OAuthFlowExposed {
const { t } = useI18n()
const authStore = useAuthStore()
const browserTimeZone = getBrowserTimeZone()
const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
@@ -1611,7 +1611,10 @@
<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" />
<p class="input-hint">{{ t('admin.accounts.expiresAtHint') }}</p>
<p class="input-hint">
{{ t('admin.accounts.expiresAtHint') }}
{{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }}
</p>
</div>
<!-- OpenAI 自动透传开关(OAuth/API Key) -->
@@ -2915,7 +2918,12 @@ import {
type CnNativeApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
import {
formatDateTime,
formatDateTimeLocalInput,
getBrowserTimeZone,
parseDateTimeLocalInput
} from '@/utils/format'
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
import { VERTEX_LOCATION_OPTIONS } from '@/constants/account'
@@ -2953,6 +2961,7 @@ const emit = defineEmits<{
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const browserTimeZone = getBrowserTimeZone()
// Spark 影子账号(parent_account_id 非空):代理恒继承母账号,不可独立编辑(外审 B/P1),
// 故隐藏代理选择器。
@@ -943,6 +943,7 @@ export default {
billingRateMultiplierHint: '0 = free, affects account billing only',
expiresAt: 'Expires At',
expiresAtHint: 'Leave empty for no expiration',
expiresAtTimezoneHint: 'Input is interpreted in your browser time zone ({timezone}).',
higherPriorityFirst: 'Lower value means higher priority',
mixedScheduling: 'Use in /v1/messages',
mixedSchedulingHint: 'Enable to participate in Anthropic/Gemini group scheduling',
@@ -329,6 +329,8 @@ export default {
customExpiry: 'Custom',
customExpiryDays: 'Custom days',
expiryDaysRequired: 'Please enter a valid expiry day count',
expiryDateRequired: 'Please enter a valid expiry date and time',
localTimeZoneHint: 'Custom time is interpreted in your browser time zone ({timezone}).',
groupRequired: 'Please select a subscription group',
days: ' days',
status: {
@@ -1014,6 +1014,7 @@ export default {
billingRateMultiplierHint: '0 表示不计费,仅影响账号计费',
expiresAt: '过期时间',
expiresAtHint: '留空表示不过期',
expiresAtTimezoneHint: '输入按浏览器本地时区({timezone})解释。',
higherPriorityFirst: '数值越小优先级越高',
mixedScheduling: '在 /v1/messages 中使用',
mixedSchedulingHint: '启用后可参与 Anthropic/Gemini 分组的调度',
@@ -320,6 +320,8 @@ export default {
customExpiry: '自定义',
customExpiryDays: '自定义天数',
expiryDaysRequired: '请输入有效的过期天数',
expiryDateRequired: '请输入有效的过期日期和时间',
localTimeZoneHint: '自定义时间按浏览器本地时区({timezone})解释。',
groupRequired: '请选择订阅分组',
days: '天',
status: {
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
formatDateTimeLocalInput,
getBrowserTimeZone,
parseDateTimeLocalInput
} from '../format'
describe('formatDateTimeLocalInput', () => {
it('round-trips a minute-precision local timestamp', () => {
const timestamp = Math.floor(new Date(2026, 7, 30, 7, 24).getTime() / 1000)
expect(formatDateTimeLocalInput(timestamp)).toBe('2026-08-30T07:24')
expect(parseDateTimeLocalInput('2026-08-30T07:24')).toBe(timestamp)
})
})
describe('parseDateTimeLocalInput', () => {
it('uses local date components and rejects timezone-bearing values', () => {
const expected = Math.floor(new Date(2026, 7, 30, 7, 24, 10).getTime() / 1000)
expect(parseDateTimeLocalInput('2026-08-30T07:24:10')).toBe(expected)
expect(parseDateTimeLocalInput('2026-08-30T07:24:10+08:00')).toBeNull()
expect(parseDateTimeLocalInput('2026-08-30T07:24:10Z')).toBeNull()
})
it('rejects empty, malformed, and overflowing calendar values', () => {
expect(parseDateTimeLocalInput('')).toBeNull()
expect(parseDateTimeLocalInput('2026-08-30 07:24')).toBeNull()
expect(parseDateTimeLocalInput('2026-02-30T07:24')).toBeNull()
expect(parseDateTimeLocalInput('2026-08-30T24:00')).toBeNull()
expect(parseDateTimeLocalInput('2026-08-30T07:60')).toBeNull()
})
it('accepts optional seconds and fractional seconds', () => {
const expected = Math.floor(new Date(2026, 7, 30, 7, 24, 10, 120).getTime() / 1000)
expect(parseDateTimeLocalInput('2026-08-30T07:24:10.12')).toBe(expected)
})
})
describe('getBrowserTimeZone', () => {
it('returns a timezone identifier or the UTC fallback', () => {
expect(getBrowserTimeZone()).toBeTruthy()
})
})
+57 -3
View File
@@ -201,9 +201,63 @@ export function formatDateTimeLocalInput(timestampSeconds: number | null): strin
*/
export function parseDateTimeLocalInput(value: string): number | null {
if (!value) return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return Math.floor(date.getTime() / 1000)
// datetime-local intentionally has no timezone. Parse its components explicitly
// so malformed values or timezone-bearing strings are not reinterpreted by Date.parse.
const match = /^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?$/.exec(value)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const hours = Number(match[4])
const minutes = Number(match[5])
const seconds = match[6] ? Number(match[6]) : 0
const milliseconds = match[7]
? Number(match[7].slice(0, 3).padEnd(3, '0'))
: 0
if (
!Number.isSafeInteger(year) ||
year < 1 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hours > 23 ||
minutes > 59 ||
seconds > 59
) {
return null
}
const date = new Date(0)
date.setFullYear(year, month - 1, day)
date.setHours(hours, minutes, seconds, milliseconds)
// Date setters normalize calendar overflows (for example, February 30). Keep
// the platform's existing DST disambiguation for otherwise valid local times.
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null
}
const timestamp = date.getTime()
return Number.isFinite(timestamp) ? Math.floor(timestamp / 1000) : null
}
/**
* 获取浏览器当前使用的 IANA 时区
*/
export function getBrowserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
/**
+13 -5
View File
@@ -462,6 +462,9 @@
type="datetime-local"
class="input"
/>
<p v-if="batchUpdateForm.expires_mode === 'custom'" class="input-hint">
{{ t('admin.redeem.localTimeZoneHint', { timezone: browserTimeZone }) }}
</p>
</template>
</div>
@@ -615,7 +618,11 @@ import { useClipboard } from '@/composables/useClipboard'
import { useTableSelection } from '@/composables/useTableSelection'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import {
formatDateTime,
getBrowserTimeZone,
parseDateTimeLocalInput
} from '@/utils/format'
import type {
RedeemCode,
RedeemCodeType,
@@ -638,6 +645,7 @@ import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
const appStore = useAppStore()
const { copyToClipboard: clipboardCopy } = useClipboard()
const browserTimeZone = getBrowserTimeZone()
interface GroupOption {
value: number
@@ -998,12 +1006,12 @@ const buildBatchUpdateFields = (): BatchUpdateRedeemCodeFields | null => {
if (batchUpdateForm.expires_mode === 'clear') {
fields.expires_at = null
} else {
const expiresAt = new Date(batchUpdateForm.expires_at_local)
if (!batchUpdateForm.expires_at_local || Number.isNaN(expiresAt.getTime())) {
appStore.showError(t('admin.redeem.expiryDaysRequired'))
const expiresAt = parseDateTimeLocalInput(batchUpdateForm.expires_at_local)
if (expiresAt === null) {
appStore.showError(t('admin.redeem.expiryDateRequired'))
return null
}
fields.expires_at = expiresAt.toISOString()
fields.expires_at = new Date(expiresAt * 1000).toISOString()
}
}
if (batchUpdateForm.update_notes) {