mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #6395 from lylelllll/fix/account-expiry-timezone
fix(frontend): clarify local timezone for expiry inputs
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user