chore: initial public snapshot for github upload
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildOpenAIUsageRefreshKey } from '../accountUsageRefresh'
|
||||
|
||||
describe('buildOpenAIUsageRefreshKey', () => {
|
||||
it('会在 codex 快照变化时生成不同 key', () => {
|
||||
const base = {
|
||||
id: 1,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {
|
||||
codex_usage_updated_at: '2026-03-07T10:00:00Z',
|
||||
codex_5h_used_percent: 0,
|
||||
codex_7d_used_percent: 0
|
||||
}
|
||||
} as any
|
||||
|
||||
const next = {
|
||||
...base,
|
||||
extra: {
|
||||
...base.extra,
|
||||
codex_usage_updated_at: '2026-03-07T10:01:00Z',
|
||||
codex_5h_used_percent: 100
|
||||
}
|
||||
}
|
||||
|
||||
expect(buildOpenAIUsageRefreshKey(base)).not.toBe(buildOpenAIUsageRefreshKey(next))
|
||||
})
|
||||
|
||||
it('非 OpenAI OAuth 账号返回空 key', () => {
|
||||
expect(buildOpenAIUsageRefreshKey({
|
||||
id: 2,
|
||||
platform: 'anthropic',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
} as any)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAuthErrorMessage } from '@/utils/authError'
|
||||
|
||||
describe('buildAuthErrorMessage', () => {
|
||||
it('prefers response detail message when available', () => {
|
||||
const message = buildAuthErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
detail: 'detailed message',
|
||||
message: 'plain message'
|
||||
}
|
||||
},
|
||||
},
|
||||
{ fallback: 'fallback' }
|
||||
)
|
||||
expect(message).toBe('detailed message')
|
||||
})
|
||||
|
||||
it('falls back to response message when detail is unavailable', () => {
|
||||
const message = buildAuthErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
message: 'plain message'
|
||||
}
|
||||
},
|
||||
},
|
||||
{ fallback: 'fallback' }
|
||||
)
|
||||
expect(message).toBe('plain message')
|
||||
})
|
||||
|
||||
it('falls back to error.message when response payload is unavailable', () => {
|
||||
const message = buildAuthErrorMessage(
|
||||
{
|
||||
message: 'error message'
|
||||
},
|
||||
{ fallback: 'fallback' }
|
||||
)
|
||||
expect(message).toBe('error message')
|
||||
})
|
||||
|
||||
it('uses fallback when no message can be extracted', () => {
|
||||
expect(buildAuthErrorMessage({}, { fallback: 'fallback' })).toBe('fallback')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||
|
||||
describe('resolveCodexUsageWindow', () => {
|
||||
it('快照为空时返回空窗口', () => {
|
||||
const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
|
||||
expect(result).toEqual({ usedPercent: null, resetAt: null })
|
||||
})
|
||||
|
||||
it('优先使用后端提供的绝对重置时间', () => {
|
||||
const now = new Date('2026-02-20T08:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 55,
|
||||
codex_5h_reset_at: '2026-02-20T10:00:00Z',
|
||||
codex_5h_reset_after_seconds: 1
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(55)
|
||||
expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
|
||||
})
|
||||
|
||||
it('窗口已过期时自动归零', () => {
|
||||
const now = new Date('2026-02-20T08:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_used_percent: 100,
|
||||
codex_7d_reset_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(0)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
|
||||
const now = new Date('2026-02-20T07:00:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 20,
|
||||
codex_5h_reset_after_seconds: 3600,
|
||||
codex_usage_updated_at: '2026-02-20T06:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(20)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
|
||||
})
|
||||
|
||||
it('支持 legacy primary/secondary 字段映射', () => {
|
||||
const now = new Date('2026-02-20T07:05:00Z')
|
||||
const result5h = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 10080,
|
||||
codex_primary_used_percent: 70,
|
||||
codex_primary_reset_after_seconds: 86400,
|
||||
codex_secondary_window_minutes: 300,
|
||||
codex_secondary_used_percent: 15,
|
||||
codex_secondary_reset_after_seconds: 1200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
const result7d = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 10080,
|
||||
codex_primary_used_percent: 70,
|
||||
codex_primary_reset_after_seconds: 86400,
|
||||
codex_secondary_window_minutes: 300,
|
||||
codex_secondary_used_percent: 15,
|
||||
codex_secondary_reset_after_seconds: 1200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result5h.usedPercent).toBe(15)
|
||||
expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
|
||||
expect(result7d.usedPercent).toBe(70)
|
||||
expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: '300',
|
||||
codex_primary_used_percent: '21',
|
||||
codex_primary_reset_after_seconds: '1800',
|
||||
codex_secondary_window_minutes: '10080',
|
||||
codex_secondary_used_percent: '99',
|
||||
codex_secondary_reset_after_seconds: '99999',
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:10:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(21)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 5h 在无窗口信息时回退 secondary', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_secondary_used_percent: 19,
|
||||
codex_secondary_reset_after_seconds: 120,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(19)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
|
||||
})
|
||||
|
||||
it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
|
||||
const now = new Date('2026-02-20T07:30:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_primary_window_minutes: 300,
|
||||
codex_primary_used_percent: 5,
|
||||
codex_primary_reset_after_seconds: 600,
|
||||
codex_secondary_window_minutes: 10080,
|
||||
codex_secondary_used_percent: 66,
|
||||
codex_secondary_reset_after_seconds: 7200,
|
||||
codex_usage_updated_at: '2026-02-20T07:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(66)
|
||||
expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
|
||||
})
|
||||
|
||||
it('绝对时间非法时回退到 updated_at + seconds', () => {
|
||||
const now = new Date('2026-02-20T07:40:00Z')
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 33,
|
||||
codex_5h_reset_at: 'not-a-date',
|
||||
codex_5h_reset_after_seconds: 900,
|
||||
codex_usage_updated_at: '2026-02-20T07:30:00Z'
|
||||
},
|
||||
'5h',
|
||||
now
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(33)
|
||||
expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
|
||||
})
|
||||
|
||||
it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 10,
|
||||
codex_5h_reset_after_seconds: 123,
|
||||
codex_usage_updated_at: 'invalid-time'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T08:00:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(10)
|
||||
expect(result.resetAt).toBeNull()
|
||||
})
|
||||
|
||||
it('reset_after_seconds 为负数时按 0 秒处理', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_5h_used_percent: 80,
|
||||
codex_5h_reset_after_seconds: -30,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'5h',
|
||||
new Date('2026-02-20T07:59:00Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBe(80)
|
||||
expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
|
||||
})
|
||||
|
||||
it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
|
||||
const result = resolveCodexUsageWindow(
|
||||
{
|
||||
codex_7d_reset_after_seconds: 60,
|
||||
codex_usage_updated_at: '2026-02-20T08:00:00Z'
|
||||
},
|
||||
'7d',
|
||||
new Date('2026-02-20T08:00:01Z')
|
||||
)
|
||||
|
||||
expect(result.usedPercent).toBeNull()
|
||||
expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildEmbeddedUrl, detectTheme } from '../embedded-url'
|
||||
|
||||
describe('embedded-url', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
origin: 'https://app.example.com',
|
||||
href: 'https://app.example.com/user/purchase',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
document.documentElement.classList.remove('dark')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('adds embedded query parameters including locale and source context', () => {
|
||||
const result = buildEmbeddedUrl(
|
||||
'https://pay.example.com/checkout?plan=pro',
|
||||
42,
|
||||
'token-123',
|
||||
'dark',
|
||||
'zh-CN',
|
||||
)
|
||||
|
||||
const url = new URL(result)
|
||||
expect(url.searchParams.get('plan')).toBe('pro')
|
||||
expect(url.searchParams.get('user_id')).toBe('42')
|
||||
expect(url.searchParams.get('token')).toBe('token-123')
|
||||
expect(url.searchParams.get('theme')).toBe('dark')
|
||||
expect(url.searchParams.get('lang')).toBe('zh-CN')
|
||||
expect(url.searchParams.get('ui_mode')).toBe('embedded')
|
||||
expect(url.searchParams.get('src_host')).toBe('https://app.example.com')
|
||||
expect(url.searchParams.get('src_url')).toBe('https://app.example.com/user/purchase')
|
||||
})
|
||||
|
||||
it('omits optional params when they are empty', () => {
|
||||
const result = buildEmbeddedUrl('https://pay.example.com/checkout', undefined, '', 'light')
|
||||
|
||||
const url = new URL(result)
|
||||
expect(url.searchParams.get('theme')).toBe('light')
|
||||
expect(url.searchParams.get('ui_mode')).toBe('embedded')
|
||||
expect(url.searchParams.has('user_id')).toBe(false)
|
||||
expect(url.searchParams.has('token')).toBe(false)
|
||||
expect(url.searchParams.has('lang')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns original string for invalid url input', () => {
|
||||
expect(buildEmbeddedUrl('not a url', 1, 'token')).toBe('not a url')
|
||||
})
|
||||
|
||||
it('detects dark mode from document root class', () => {
|
||||
document.documentElement.classList.add('dark')
|
||||
expect(detectTheme()).toBe('dark')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
OPENAI_WS_MODE_OFF,
|
||||
OPENAI_WS_MODE_PASSTHROUGH,
|
||||
isOpenAIWSModeEnabled,
|
||||
normalizeOpenAIWSMode,
|
||||
openAIWSModeFromEnabled,
|
||||
resolveOpenAIWSModeConcurrencyHintKey,
|
||||
resolveOpenAIWSModeFromExtra
|
||||
} from '@/utils/openaiWsMode'
|
||||
|
||||
describe('openaiWsMode utils', () => {
|
||||
it('normalizes mode values', () => {
|
||||
expect(normalizeOpenAIWSMode('off')).toBe(OPENAI_WS_MODE_OFF)
|
||||
expect(normalizeOpenAIWSMode('ctx_pool')).toBe(OPENAI_WS_MODE_CTX_POOL)
|
||||
expect(normalizeOpenAIWSMode('passthrough')).toBe(OPENAI_WS_MODE_PASSTHROUGH)
|
||||
expect(normalizeOpenAIWSMode(' Shared ')).toBe(OPENAI_WS_MODE_CTX_POOL)
|
||||
expect(normalizeOpenAIWSMode('DEDICATED')).toBe(OPENAI_WS_MODE_CTX_POOL)
|
||||
expect(normalizeOpenAIWSMode('invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('maps legacy enabled flag to mode', () => {
|
||||
expect(openAIWSModeFromEnabled(true)).toBe(OPENAI_WS_MODE_CTX_POOL)
|
||||
expect(openAIWSModeFromEnabled(false)).toBe(OPENAI_WS_MODE_OFF)
|
||||
expect(openAIWSModeFromEnabled('true')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves by mode key first, then enabled, then fallback enabled keys', () => {
|
||||
const extra = {
|
||||
openai_oauth_responses_websockets_v2_mode: 'passthrough',
|
||||
openai_oauth_responses_websockets_v2_enabled: false,
|
||||
responses_websockets_v2_enabled: false
|
||||
}
|
||||
const mode = resolveOpenAIWSModeFromExtra(extra, {
|
||||
modeKey: 'openai_oauth_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_oauth_responses_websockets_v2_enabled',
|
||||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled']
|
||||
})
|
||||
expect(mode).toBe(OPENAI_WS_MODE_PASSTHROUGH)
|
||||
})
|
||||
|
||||
it('falls back to default when nothing is present', () => {
|
||||
const mode = resolveOpenAIWSModeFromExtra({}, {
|
||||
modeKey: 'openai_apikey_responses_websockets_v2_mode',
|
||||
enabledKey: 'openai_apikey_responses_websockets_v2_enabled',
|
||||
fallbackEnabledKeys: ['responses_websockets_v2_enabled', 'openai_ws_enabled'],
|
||||
defaultMode: OPENAI_WS_MODE_OFF
|
||||
})
|
||||
expect(mode).toBe(OPENAI_WS_MODE_OFF)
|
||||
})
|
||||
|
||||
it('treats off as disabled and non-off modes as enabled', () => {
|
||||
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_OFF)).toBe(false)
|
||||
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_CTX_POOL)).toBe(true)
|
||||
expect(isOpenAIWSModeEnabled(OPENAI_WS_MODE_PASSTHROUGH)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves concurrency hint key by mode', () => {
|
||||
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_OFF)).toBe(
|
||||
'admin.accounts.openai.wsModeConcurrencyHint'
|
||||
)
|
||||
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_CTX_POOL)).toBe(
|
||||
'admin.accounts.openai.wsModeConcurrencyHint'
|
||||
)
|
||||
expect(resolveOpenAIWSModeConcurrencyHintKey(OPENAI_WS_MODE_PASSTHROUGH)).toBe(
|
||||
'admin.accounts.openai.wsModePassthroughHint'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRegistrationEmailSuffixAllowed,
|
||||
isRegistrationEmailSuffixDomainValid,
|
||||
normalizeRegistrationEmailSuffixDomain,
|
||||
normalizeRegistrationEmailSuffixDomains,
|
||||
normalizeRegistrationEmailSuffixWhitelist,
|
||||
parseRegistrationEmailSuffixWhitelistInput
|
||||
} from '@/utils/registrationEmailPolicy'
|
||||
|
||||
describe('registrationEmailPolicy utils', () => {
|
||||
it('normalizeRegistrationEmailSuffixDomain lowercases, strips @, and ignores invalid chars', () => {
|
||||
expect(normalizeRegistrationEmailSuffixDomain(' @Exa!mple.COM ')).toBe('example.com')
|
||||
})
|
||||
|
||||
it('normalizeRegistrationEmailSuffixDomains deduplicates normalized domains', () => {
|
||||
expect(
|
||||
normalizeRegistrationEmailSuffixDomains([
|
||||
'@example.com',
|
||||
'Example.com',
|
||||
'',
|
||||
'-invalid.com',
|
||||
'foo..bar.com',
|
||||
' @foo.bar ',
|
||||
'@foo.bar'
|
||||
])
|
||||
).toEqual(['example.com', 'foo.bar'])
|
||||
})
|
||||
|
||||
it('parseRegistrationEmailSuffixWhitelistInput supports separators and deduplicates', () => {
|
||||
const input = '\n @example.com,example.com,@foo.bar\t@FOO.bar '
|
||||
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['example.com', 'foo.bar'])
|
||||
})
|
||||
|
||||
it('parseRegistrationEmailSuffixWhitelistInput drops tokens containing invalid chars', () => {
|
||||
const input = '@exa!mple.com, @foo.bar, @bad#token.com, @ok-domain.com'
|
||||
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'ok-domain.com'])
|
||||
})
|
||||
|
||||
it('parseRegistrationEmailSuffixWhitelistInput drops structurally invalid domains', () => {
|
||||
const input = '@-bad.com, @foo..bar.com, @foo.bar, @xn--ok.com'
|
||||
expect(parseRegistrationEmailSuffixWhitelistInput(input)).toEqual(['foo.bar', 'xn--ok.com'])
|
||||
})
|
||||
|
||||
it('parseRegistrationEmailSuffixWhitelistInput returns empty list for blank input', () => {
|
||||
expect(parseRegistrationEmailSuffixWhitelistInput(' \n \n')).toEqual([])
|
||||
})
|
||||
|
||||
it('normalizeRegistrationEmailSuffixWhitelist returns canonical @domain list', () => {
|
||||
expect(
|
||||
normalizeRegistrationEmailSuffixWhitelist([
|
||||
'@Example.com',
|
||||
'foo.bar',
|
||||
'',
|
||||
'-invalid.com',
|
||||
' @foo.bar '
|
||||
])
|
||||
).toEqual(['@example.com', '@foo.bar'])
|
||||
})
|
||||
|
||||
it('isRegistrationEmailSuffixDomainValid matches backend-compatible domain rules', () => {
|
||||
expect(isRegistrationEmailSuffixDomainValid('example.com')).toBe(true)
|
||||
expect(isRegistrationEmailSuffixDomainValid('foo-bar.example.com')).toBe(true)
|
||||
expect(isRegistrationEmailSuffixDomainValid('-bad.com')).toBe(false)
|
||||
expect(isRegistrationEmailSuffixDomainValid('foo..bar.com')).toBe(false)
|
||||
expect(isRegistrationEmailSuffixDomainValid('localhost')).toBe(false)
|
||||
})
|
||||
|
||||
it('isRegistrationEmailSuffixAllowed allows any email when whitelist is empty', () => {
|
||||
expect(isRegistrationEmailSuffixAllowed('user@example.com', [])).toBe(true)
|
||||
})
|
||||
|
||||
it('isRegistrationEmailSuffixAllowed applies exact suffix matching', () => {
|
||||
expect(isRegistrationEmailSuffixAllowed('user@example.com', ['@example.com'])).toBe(true)
|
||||
expect(isRegistrationEmailSuffixAllowed('user@sub.example.com', ['@example.com'])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSoraRawTokens } from '@/utils/soraTokenParser'
|
||||
|
||||
describe('parseSoraRawTokens', () => {
|
||||
it('parses sessionToken and accessToken from JSON payload', () => {
|
||||
const payload = JSON.stringify({
|
||||
user: { id: 'u1' },
|
||||
accessToken: 'at-json-1',
|
||||
sessionToken: 'st-json-1'
|
||||
})
|
||||
|
||||
const result = parseSoraRawTokens(payload)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-json-1'])
|
||||
expect(result.accessTokens).toEqual(['at-json-1'])
|
||||
})
|
||||
|
||||
it('supports plain session tokens (one per line)', () => {
|
||||
const result = parseSoraRawTokens('st-1\nst-2')
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-1', 'st-2'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('supports non-standard object snippets via regex', () => {
|
||||
const raw = "sessionToken: 'st-snippet', access_token: \"at-snippet\""
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-snippet'])
|
||||
expect(result.accessTokens).toEqual(['at-snippet'])
|
||||
})
|
||||
|
||||
it('keeps unique tokens and extracts JWT-like plain line as AT too', () => {
|
||||
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature'
|
||||
const raw = `st-dup\nst-dup\n${jwt}\n${JSON.stringify({ sessionToken: 'st-json', accessToken: jwt })}`
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-json', 'st-dup'])
|
||||
expect(result.accessTokens).toEqual([jwt])
|
||||
})
|
||||
|
||||
it('parses session token from Set-Cookie line and strips cookie attributes', () => {
|
||||
const raw =
|
||||
'__Secure-next-auth.session-token.0=st-cookie-part-0; Domain=.chatgpt.com; Path=/; Expires=Thu, 28 May 2026 11:43:36 GMT; HttpOnly; Secure; SameSite=Lax'
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['st-cookie-part-0'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('merges chunked session-token cookies by numeric suffix order', () => {
|
||||
const raw = [
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=part-1; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=part-0; Path=/; HttpOnly'
|
||||
].join('\n')
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['part-0part-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers latest duplicate chunk values when multiple cookie groups exist', () => {
|
||||
const raw = [
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=old-0; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=old-1; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.0=new-0; Path=/; HttpOnly',
|
||||
'Set-Cookie: __Secure-next-auth.session-token.1=new-1; Path=/; HttpOnly'
|
||||
].join('\n')
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['new-0new-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
|
||||
it('uses latest complete chunk group and ignores incomplete latest group', () => {
|
||||
const raw = [
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.0=ok-0; Domain=.chatgpt.com; Path=/',
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.1=ok-1; Domain=.chatgpt.com; Path=/',
|
||||
'set-cookie',
|
||||
'__Secure-next-auth.session-token.0=partial-0; Domain=.chatgpt.com; Path=/'
|
||||
].join('\n')
|
||||
|
||||
const result = parseSoraRawTokens(raw)
|
||||
|
||||
expect(result.sessionTokens).toEqual(['ok-0ok-1'])
|
||||
expect(result.accessTokens).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
describe('createStableObjectKeyResolver', () => {
|
||||
it('对同一对象返回稳定 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
const obj = { value: 'a' }
|
||||
|
||||
const key1 = resolve(obj)
|
||||
const key2 = resolve(obj)
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
expect(key1.startsWith('rule-')).toBe(true)
|
||||
})
|
||||
|
||||
it('不同对象返回不同 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
|
||||
const key1 = resolve({ value: 'a' })
|
||||
const key2 = resolve({ value: 'a' })
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('不同 resolver 互不影响', () => {
|
||||
const resolveA = createStableObjectKeyResolver<{ id: number }>('a')
|
||||
const resolveB = createStableObjectKeyResolver<{ id: number }>('b')
|
||||
const obj = { id: 1 }
|
||||
|
||||
const keyA = resolveA(obj)
|
||||
const keyB = resolveB(obj)
|
||||
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA.startsWith('a-')).toBe(true)
|
||||
expect(keyB.startsWith('b-')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatUsageServiceTier, getUsageServiceTierLabel, normalizeUsageServiceTier } from '@/utils/usageServiceTier'
|
||||
|
||||
describe('usageServiceTier utils', () => {
|
||||
it('normalizes fast/default aliases', () => {
|
||||
expect(normalizeUsageServiceTier('fast')).toBe('priority')
|
||||
expect(normalizeUsageServiceTier(' default ')).toBe('standard')
|
||||
expect(normalizeUsageServiceTier('STANDARD')).toBe('standard')
|
||||
})
|
||||
|
||||
it('preserves supported tiers', () => {
|
||||
expect(normalizeUsageServiceTier('priority')).toBe('priority')
|
||||
expect(normalizeUsageServiceTier('flex')).toBe('flex')
|
||||
})
|
||||
|
||||
it('formats empty values as standard', () => {
|
||||
expect(formatUsageServiceTier()).toBe('standard')
|
||||
expect(formatUsageServiceTier('')).toBe('standard')
|
||||
})
|
||||
|
||||
it('passes through unknown non-empty tiers for display fallback', () => {
|
||||
expect(normalizeUsageServiceTier('custom-tier')).toBe('custom-tier')
|
||||
expect(formatUsageServiceTier('custom-tier')).toBe('custom-tier')
|
||||
})
|
||||
|
||||
it('maps tiers to translated labels', () => {
|
||||
const translate = (key: string) => ({
|
||||
'usage.serviceTierPriority': 'Fast',
|
||||
'usage.serviceTierFlex': 'Flex',
|
||||
'usage.serviceTierStandard': 'Standard',
|
||||
})[key] ?? key
|
||||
|
||||
expect(getUsageServiceTierLabel('fast', translate)).toBe('Fast')
|
||||
expect(getUsageServiceTierLabel('flex', translate)).toBe('Flex')
|
||||
expect(getUsageServiceTierLabel(undefined, translate)).toBe('Standard')
|
||||
expect(getUsageServiceTierLabel('custom-tier', translate)).toBe('custom-tier')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const normalizeUsageRefreshValue = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const buildOpenAIUsageRefreshKey = (account: Pick<Account, 'id' | 'platform' | 'type' | 'updated_at' | 'rate_limit_reset_at' | 'extra'>): string => {
|
||||
if (account.platform !== 'openai' || account.type !== 'oauth') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const extra = account.extra ?? {}
|
||||
return [
|
||||
account.id,
|
||||
account.updated_at,
|
||||
account.rate_limit_reset_at,
|
||||
extra.codex_usage_updated_at,
|
||||
extra.codex_5h_used_percent,
|
||||
extra.codex_5h_reset_at,
|
||||
extra.codex_5h_reset_after_seconds,
|
||||
extra.codex_5h_window_minutes,
|
||||
extra.codex_7d_used_percent,
|
||||
extra.codex_7d_reset_at,
|
||||
extra.codex_7d_reset_after_seconds,
|
||||
extra.codex_7d_window_minutes
|
||||
].map(normalizeUsageRefreshValue).join('|')
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
interface APIErrorLike {
|
||||
message?: string
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
const err = (error || {}) as APIErrorLike
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || ''
|
||||
}
|
||||
|
||||
export function buildAuthErrorMessage(
|
||||
error: unknown,
|
||||
options: {
|
||||
fallback: string
|
||||
}
|
||||
): string {
|
||||
const { fallback } = options
|
||||
const message = extractErrorMessage(error)
|
||||
return message || fallback
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { CodexUsageSnapshot } from '@/types'
|
||||
|
||||
export interface ResolvedCodexUsageWindow {
|
||||
usedPercent: number | null
|
||||
resetAt: string | null
|
||||
}
|
||||
|
||||
type WindowKind = '5h' | '7d'
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n)) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const trimmed = value.trim()
|
||||
return trimmed === '' ? null : trimmed
|
||||
}
|
||||
|
||||
function asISOTime(value: unknown): string | null {
|
||||
const raw = asString(value)
|
||||
if (!raw) return null
|
||||
const date = new Date(raw)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function resolveLegacy5h(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow <= 360) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow <= 360) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
|
||||
function resolveLegacy7d(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
|
||||
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
|
||||
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
|
||||
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
|
||||
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
|
||||
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
|
||||
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
|
||||
|
||||
if (primaryWindow != null && primaryWindow >= 10000) {
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
if (secondaryWindow != null && secondaryWindow >= 10000) {
|
||||
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
|
||||
}
|
||||
return { used: primaryUsed, resetAfterSeconds: primaryReset }
|
||||
}
|
||||
|
||||
function resolveFromSeconds(snapshot: Record<string, unknown>, resetAfterSeconds: number | null): string | null {
|
||||
if (resetAfterSeconds == null) return null
|
||||
|
||||
const baseRaw = asString(snapshot.codex_usage_updated_at)
|
||||
const base = baseRaw ? new Date(baseRaw) : new Date()
|
||||
if (Number.isNaN(base.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sec = Math.max(0, resetAfterSeconds)
|
||||
const resetAt = new Date(base.getTime() + sec * 1000)
|
||||
return resetAt.toISOString()
|
||||
}
|
||||
|
||||
function applyExpiredRule(window: ResolvedCodexUsageWindow, now: Date): ResolvedCodexUsageWindow {
|
||||
if (window.usedPercent == null || !window.resetAt) return window
|
||||
const resetDate = new Date(window.resetAt)
|
||||
if (Number.isNaN(resetDate.getTime())) return window
|
||||
if (resetDate.getTime() <= now.getTime()) {
|
||||
return { usedPercent: 0, resetAt: resetDate.toISOString() }
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
export function resolveCodexUsageWindow(
|
||||
snapshot: (CodexUsageSnapshot & Record<string, unknown>) | null | undefined,
|
||||
window: WindowKind,
|
||||
now: Date = new Date()
|
||||
): ResolvedCodexUsageWindow {
|
||||
if (!snapshot) {
|
||||
return { usedPercent: null, resetAt: null }
|
||||
}
|
||||
|
||||
const typedSnapshot = snapshot as Record<string, unknown>
|
||||
let usedPercent: number | null
|
||||
let resetAfterSeconds: number | null
|
||||
let resetAt: string | null
|
||||
|
||||
if (window === '5h') {
|
||||
usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy5h(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
} else {
|
||||
usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
|
||||
resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
|
||||
resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
|
||||
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
|
||||
const legacy = resolveLegacy7d(typedSnapshot)
|
||||
if (usedPercent == null) usedPercent = legacy.used
|
||||
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
if (!resetAt) {
|
||||
resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
|
||||
}
|
||||
|
||||
return applyExpiredRule({ usedPercent, resetAt }, now)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Shared URL builder for iframe-embedded pages.
|
||||
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
|
||||
* with user_id, token, theme, lang, ui_mode, src_host, and src parameters.
|
||||
*/
|
||||
|
||||
const EMBEDDED_USER_ID_QUERY_KEY = 'user_id'
|
||||
const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token'
|
||||
const EMBEDDED_THEME_QUERY_KEY = 'theme'
|
||||
const EMBEDDED_LANG_QUERY_KEY = 'lang'
|
||||
const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode'
|
||||
const EMBEDDED_UI_MODE_VALUE = 'embedded'
|
||||
const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host'
|
||||
const EMBEDDED_SRC_QUERY_KEY = 'src_url'
|
||||
|
||||
export function buildEmbeddedUrl(
|
||||
baseUrl: string,
|
||||
userId?: number,
|
||||
authToken?: string | null,
|
||||
theme: 'light' | 'dark' = 'light',
|
||||
lang?: string,
|
||||
): string {
|
||||
if (!baseUrl) return baseUrl
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (userId) {
|
||||
url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId))
|
||||
}
|
||||
if (authToken) {
|
||||
url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken)
|
||||
}
|
||||
url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme)
|
||||
if (lang) {
|
||||
url.searchParams.set(EMBEDDED_LANG_QUERY_KEY, lang)
|
||||
}
|
||||
url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE)
|
||||
// Source tracking: let the embedded page know where it's being loaded from
|
||||
if (typeof window !== 'undefined') {
|
||||
url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin)
|
||||
url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href)
|
||||
}
|
||||
return url.toString()
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
export function detectTheme(): 'light' | 'dark' {
|
||||
if (typeof document === 'undefined') return 'light'
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
312
llm-gateway-competitors/sub2api-tar/frontend/src/utils/format.ts
Normal file
312
llm-gateway-competitors/sub2api-tar/frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
* 参考 CRS 项目的 format.js 实现
|
||||
*/
|
||||
|
||||
import { i18n, getLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 相对时间字符串,如 "5m ago", "2h ago", "3d ago"
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return i18n.global.t('common.time.never')
|
||||
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const diffMs = now.getTime() - past.getTime()
|
||||
|
||||
// 处理未来时间或无效日期
|
||||
if (diffMs < 0 || isNaN(diffMs)) return i18n.global.t('common.time.never')
|
||||
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return i18n.global.t('common.time.daysAgo', { n: diffDays })
|
||||
if (diffHours > 0) return i18n.global.t('common.time.hoursAgo', { n: diffHours })
|
||||
if (diffMins > 0) return i18n.global.t('common.time.minutesAgo', { n: diffMins })
|
||||
return i18n.global.t('common.time.justNow')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(支持 K/M/B 单位)
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串,如 "1.2K", "3.5M"
|
||||
*/
|
||||
export function formatNumber(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '0'
|
||||
|
||||
const locale = getLocale()
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
// Use Intl.NumberFormat for compact notation if supported and needed
|
||||
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
notation: absNum >= 10000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1
|
||||
})
|
||||
|
||||
return formatter.format(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化货币金额
|
||||
* @param amount 金额
|
||||
* @param currency 货币代码,默认 USD
|
||||
* @returns 格式化后的字符串,如 "$1.25"
|
||||
*/
|
||||
export function formatCurrency(amount: number | null | undefined, currency: string = 'USD'): string {
|
||||
if (amount === null || amount === undefined) return '$0.00'
|
||||
|
||||
const locale = getLocale()
|
||||
|
||||
// For very small amounts, show more decimals
|
||||
const fractionDigits = amount > 0 && amount < 0.01 ? 6 : 2
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
* @param bytes 字节数
|
||||
* @param decimals 小数位数
|
||||
* @returns 格式化后的字符串,如 "1.5 MB"
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param options Intl.DateTimeFormatOptions
|
||||
* @param localeOverride 可选 locale 覆盖
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(
|
||||
date: string | Date | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
localeOverride?: string
|
||||
): string {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const locale = localeOverride ?? getLocale()
|
||||
return new Intl.DateTimeFormat(locale, options).format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(只显示日期部分)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDateOnly(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(完整格式)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param options Intl.DateTimeFormatOptions
|
||||
* @param localeOverride 可选 locale 覆盖
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatDateTime(
|
||||
date: string | Date | null | undefined,
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
localeOverride?: string
|
||||
): string {
|
||||
return formatDate(date, options, localeOverride)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 datetime-local 控件值(YYYY-MM-DDTHH:mm,使用本地时间)
|
||||
*/
|
||||
export function formatDateTimeLocalInput(timestampSeconds: number | null): string {
|
||||
if (!timestampSeconds) return ''
|
||||
const date = new Date(timestampSeconds * 1000)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 datetime-local 控件值为时间戳(秒,使用本地时间)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 OpenAI reasoning effort(用于使用记录展示)
|
||||
* @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh")
|
||||
* @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-"
|
||||
*/
|
||||
export function formatReasoningEffort(effort: string | null | undefined): string {
|
||||
const raw = (effort ?? '').toString().trim()
|
||||
if (!raw) return '-'
|
||||
|
||||
const normalized = raw.toLowerCase().replace(/[-_\s]/g, '')
|
||||
switch (normalized) {
|
||||
case 'low':
|
||||
return 'Low'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'xhigh':
|
||||
case 'extrahigh':
|
||||
return 'Xhigh'
|
||||
case 'none':
|
||||
case 'minimal':
|
||||
return '-'
|
||||
default:
|
||||
// best-effort: Title-case first letter
|
||||
return raw.length > 1 ? raw[0].toUpperCase() + raw.slice(1) : raw.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(显示时分秒)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
export function formatTime(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(千分位分隔,不使用紧凑单位)
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串,如 "12,345"
|
||||
*/
|
||||
export function formatNumberLocaleString(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(固定小数位,不带货币符号)
|
||||
* @param amount 金额
|
||||
* @param fractionDigits 小数位数,默认 4
|
||||
* @returns 格式化后的字符串,如 "1.2345"
|
||||
*/
|
||||
export function formatCostFixed(amount: number, fractionDigits: number = 4): string {
|
||||
return amount.toFixed(fractionDigits)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 token 数量(>=1M 显示为 M,>=1K 显示为 K,保留 1 位小数)
|
||||
* @param tokens token 数量
|
||||
* @returns 格式化后的字符串,如 "950", "1.2K", "3.5M"
|
||||
*/
|
||||
export function formatTokensK(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时(从现在到目标时间的剩余时间)
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
|
||||
*/
|
||||
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
|
||||
if (!targetDate) return null
|
||||
|
||||
const now = new Date()
|
||||
const target = new Date(targetDate)
|
||||
const diffMs = target.getTime() - now.getTime()
|
||||
|
||||
// 如果目标时间已过或无效
|
||||
if (diffMs <= 0 || isNaN(diffMs)) return null
|
||||
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60))
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
const remainingHours = diffHours % 24
|
||||
const remainingMins = diffMins % 60
|
||||
|
||||
if (diffDays > 0) {
|
||||
// 超过1天:显示 "Xd Yh"
|
||||
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
// 小于1天:显示 "Xh Ym"
|
||||
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
|
||||
}
|
||||
// 小于1小时:显示 "Ym"
|
||||
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
|
||||
* @param targetDate 目标日期字符串或 Date 对象
|
||||
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
|
||||
*/
|
||||
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
|
||||
const countdown = formatCountdown(targetDate)
|
||||
if (!countdown) return null
|
||||
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为相对时间 + 具体时间组合
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 组合时间字符串,如 "5 天前 · 2026-01-27 15:25"
|
||||
*/
|
||||
export function formatRelativeWithDateTime(date: string | Date | null | undefined): string {
|
||||
if (!date) return ''
|
||||
|
||||
const relativeTime = formatRelativeTime(date)
|
||||
const dateTime = formatDateTime(date)
|
||||
|
||||
// 如果是 "从未" 或空字符串,只返回相对时间
|
||||
if (!dateTime || relativeTime === i18n.global.t('common.time.never')) {
|
||||
return relativeTime
|
||||
}
|
||||
|
||||
return `${relativeTime} · ${dateTime}`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export const OPENAI_WS_MODE_OFF = 'off'
|
||||
export const OPENAI_WS_MODE_CTX_POOL = 'ctx_pool'
|
||||
export const OPENAI_WS_MODE_PASSTHROUGH = 'passthrough'
|
||||
|
||||
export type OpenAIWSMode =
|
||||
| typeof OPENAI_WS_MODE_OFF
|
||||
| typeof OPENAI_WS_MODE_CTX_POOL
|
||||
| typeof OPENAI_WS_MODE_PASSTHROUGH
|
||||
|
||||
const OPENAI_WS_MODES = new Set<OpenAIWSMode>([
|
||||
OPENAI_WS_MODE_OFF,
|
||||
OPENAI_WS_MODE_CTX_POOL,
|
||||
OPENAI_WS_MODE_PASSTHROUGH
|
||||
])
|
||||
|
||||
export interface ResolveOpenAIWSModeOptions {
|
||||
modeKey: string
|
||||
enabledKey: string
|
||||
fallbackEnabledKeys?: string[]
|
||||
defaultMode?: OpenAIWSMode
|
||||
}
|
||||
|
||||
export const normalizeOpenAIWSMode = (mode: unknown): OpenAIWSMode | null => {
|
||||
if (typeof mode !== 'string') return null
|
||||
const normalized = mode.trim().toLowerCase()
|
||||
if (normalized === 'shared' || normalized === 'dedicated') {
|
||||
return OPENAI_WS_MODE_CTX_POOL
|
||||
}
|
||||
if (OPENAI_WS_MODES.has(normalized as OpenAIWSMode)) {
|
||||
return normalized as OpenAIWSMode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const openAIWSModeFromEnabled = (enabled: unknown): OpenAIWSMode | null => {
|
||||
if (typeof enabled !== 'boolean') return null
|
||||
return enabled ? OPENAI_WS_MODE_CTX_POOL : OPENAI_WS_MODE_OFF
|
||||
}
|
||||
|
||||
export const isOpenAIWSModeEnabled = (mode: OpenAIWSMode): boolean => {
|
||||
return mode !== OPENAI_WS_MODE_OFF
|
||||
}
|
||||
|
||||
export const resolveOpenAIWSModeConcurrencyHintKey = (
|
||||
mode: OpenAIWSMode
|
||||
): 'admin.accounts.openai.wsModeConcurrencyHint' | 'admin.accounts.openai.wsModePassthroughHint' => {
|
||||
if (mode === OPENAI_WS_MODE_PASSTHROUGH) {
|
||||
return 'admin.accounts.openai.wsModePassthroughHint'
|
||||
}
|
||||
return 'admin.accounts.openai.wsModeConcurrencyHint'
|
||||
}
|
||||
|
||||
export const resolveOpenAIWSModeFromExtra = (
|
||||
extra: Record<string, unknown> | null | undefined,
|
||||
options: ResolveOpenAIWSModeOptions
|
||||
): OpenAIWSMode => {
|
||||
const fallback = options.defaultMode ?? OPENAI_WS_MODE_OFF
|
||||
if (!extra) return fallback
|
||||
|
||||
const mode = normalizeOpenAIWSMode(extra[options.modeKey])
|
||||
if (mode) return mode
|
||||
|
||||
const enabledMode = openAIWSModeFromEnabled(extra[options.enabledKey])
|
||||
if (enabledMode) return enabledMode
|
||||
|
||||
const fallbackKeys = options.fallbackEnabledKeys ?? []
|
||||
for (const key of fallbackKeys) {
|
||||
const modeFromFallbackKey = openAIWSModeFromEnabled(extra[key])
|
||||
if (modeFromFallbackKey) return modeFromFallbackKey
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
const EMAIL_SUFFIX_TOKEN_SPLIT_RE = /[\s,,]+/
|
||||
const EMAIL_SUFFIX_INVALID_CHAR_RE = /[^a-z0-9.-]/g
|
||||
const EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE = /[^a-z0-9.-]/
|
||||
const EMAIL_SUFFIX_PREFIX_RE = /^@+/
|
||||
const EMAIL_SUFFIX_DOMAIN_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/
|
||||
|
||||
// normalizeRegistrationEmailSuffixDomain converts raw input into a canonical domain token.
|
||||
// It removes leading "@", lowercases input, and strips all invalid characters.
|
||||
export function normalizeRegistrationEmailSuffixDomain(raw: string): string {
|
||||
let value = String(raw || '').trim().toLowerCase()
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
||||
value = value.replace(EMAIL_SUFFIX_INVALID_CHAR_RE, '')
|
||||
return value
|
||||
}
|
||||
|
||||
export function normalizeRegistrationEmailSuffixDomains(
|
||||
items: string[] | null | undefined
|
||||
): string[] {
|
||||
if (!items || items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
for (const item of items) {
|
||||
const domain = normalizeRegistrationEmailSuffixDomain(item)
|
||||
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
|
||||
continue
|
||||
}
|
||||
seen.add(domain)
|
||||
normalized.push(domain)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function parseRegistrationEmailSuffixWhitelistInput(input: string): string[] {
|
||||
if (!input || !input.trim()) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const normalized: string[] = []
|
||||
|
||||
for (const token of input.split(EMAIL_SUFFIX_TOKEN_SPLIT_RE)) {
|
||||
const domain = normalizeRegistrationEmailSuffixDomainStrict(token)
|
||||
if (!isRegistrationEmailSuffixDomainValid(domain) || seen.has(domain)) {
|
||||
continue
|
||||
}
|
||||
seen.add(domain)
|
||||
normalized.push(domain)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeRegistrationEmailSuffixWhitelist(
|
||||
items: string[] | null | undefined
|
||||
): string[] {
|
||||
return normalizeRegistrationEmailSuffixDomains(items).map((domain) => `@${domain}`)
|
||||
}
|
||||
|
||||
function extractRegistrationEmailDomain(email: string): string {
|
||||
const raw = String(email || '').trim().toLowerCase()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
const atIndex = raw.indexOf('@')
|
||||
if (atIndex <= 0 || atIndex >= raw.length - 1) {
|
||||
return ''
|
||||
}
|
||||
if (raw.indexOf('@', atIndex + 1) !== -1) {
|
||||
return ''
|
||||
}
|
||||
return raw.slice(atIndex + 1)
|
||||
}
|
||||
|
||||
export function isRegistrationEmailSuffixAllowed(
|
||||
email: string,
|
||||
whitelist: string[] | null | undefined
|
||||
): boolean {
|
||||
const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(whitelist)
|
||||
if (normalizedWhitelist.length === 0) {
|
||||
return true
|
||||
}
|
||||
const emailDomain = extractRegistrationEmailDomain(email)
|
||||
if (!emailDomain) {
|
||||
return false
|
||||
}
|
||||
const emailSuffix = `@${emailDomain}`
|
||||
return normalizedWhitelist.includes(emailSuffix)
|
||||
}
|
||||
|
||||
// Pasted domains should be strict: any invalid character drops the whole token.
|
||||
function normalizeRegistrationEmailSuffixDomainStrict(raw: string): string {
|
||||
let value = String(raw || '').trim().toLowerCase()
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
value = value.replace(EMAIL_SUFFIX_PREFIX_RE, '')
|
||||
if (!value || EMAIL_SUFFIX_INVALID_CHAR_CHECK_RE.test(value)) {
|
||||
return ''
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function isRegistrationEmailSuffixDomainValid(domain: string): boolean {
|
||||
if (!domain) {
|
||||
return false
|
||||
}
|
||||
return EMAIL_SUFFIX_DOMAIN_PATTERN.test(domain)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
if (!svg) return ''
|
||||
return DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
export interface ParsedSoraTokens {
|
||||
sessionTokens: string[]
|
||||
accessTokens: string[]
|
||||
}
|
||||
|
||||
const sessionKeyNames = new Set(['sessiontoken', 'session_token', 'st'])
|
||||
const accessKeyNames = new Set(['accesstoken', 'access_token', 'at'])
|
||||
|
||||
const sessionRegexes = [
|
||||
/\bsessionToken\b\s*:\s*["']([^"']+)["']/gi,
|
||||
/\bsession_token\b\s*:\s*["']([^"']+)["']/gi
|
||||
]
|
||||
|
||||
const accessRegexes = [
|
||||
/\baccessToken\b\s*:\s*["']([^"']+)["']/gi,
|
||||
/\baccess_token\b\s*:\s*["']([^"']+)["']/gi
|
||||
]
|
||||
|
||||
const sessionCookieRegex =
|
||||
/(?:^|[\n\r;])\s*(?:(?:set-cookie|cookie)\s*:\s*)?__Secure-(?:next-auth|authjs)\.session-token(?:\.(\d+))?=([^;\r\n]+)/gi
|
||||
|
||||
interface SessionCookieChunk {
|
||||
index: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const ignoredPlainLines = new Set([
|
||||
'set-cookie',
|
||||
'cookie',
|
||||
'strict-transport-security',
|
||||
'vary',
|
||||
'x-content-type-options',
|
||||
'x-openai-proxy-wasm'
|
||||
])
|
||||
|
||||
function sanitizeToken(raw: string): string {
|
||||
return raw.trim().replace(/^["'`]+|["'`,;]+$/g, '')
|
||||
}
|
||||
|
||||
function addUnique(list: string[], seen: Set<string>, rawValue: string): void {
|
||||
const token = sanitizeToken(rawValue)
|
||||
if (!token || seen.has(token)) {
|
||||
return
|
||||
}
|
||||
seen.add(token)
|
||||
list.push(token)
|
||||
}
|
||||
|
||||
function isLikelyJWT(token: string): boolean {
|
||||
if (!token.startsWith('eyJ')) {
|
||||
return false
|
||||
}
|
||||
return token.split('.').length === 3
|
||||
}
|
||||
|
||||
function collectFromObject(
|
||||
value: unknown,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
collectFromObject(item, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, fieldValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof fieldValue === 'string') {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (sessionKeyNames.has(normalizedKey)) {
|
||||
addUnique(sessionTokens, sessionSeen, fieldValue)
|
||||
}
|
||||
if (accessKeyNames.has(normalizedKey)) {
|
||||
addUnique(accessTokens, accessSeen, fieldValue)
|
||||
}
|
||||
continue
|
||||
}
|
||||
collectFromObject(fieldValue, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromJSONString(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = [trimmed]
|
||||
const firstBrace = trimmed.indexOf('{')
|
||||
const lastBrace = trimmed.lastIndexOf('}')
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
candidates.push(trimmed.slice(firstBrace, lastBrace + 1))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(candidate)
|
||||
collectFromObject(parsed, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
return
|
||||
} catch {
|
||||
// ignore and keep trying other candidates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectByRegex(
|
||||
raw: string,
|
||||
regexes: RegExp[],
|
||||
tokens: string[],
|
||||
seen: Set<string>
|
||||
): void {
|
||||
for (const regex of regexes) {
|
||||
regex.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
match = regex.exec(raw)
|
||||
while (match) {
|
||||
if (match[1]) {
|
||||
addUnique(tokens, seen, match[1])
|
||||
}
|
||||
match = regex.exec(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromSessionCookies(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>
|
||||
): void {
|
||||
const chunkMatches: SessionCookieChunk[] = []
|
||||
const singleValues: string[] = []
|
||||
|
||||
sessionCookieRegex.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
match = sessionCookieRegex.exec(raw)
|
||||
while (match) {
|
||||
const chunkIndex = match[1]
|
||||
const rawValue = match[2]
|
||||
const value = sanitizeToken(rawValue || '')
|
||||
if (value) {
|
||||
if (chunkIndex !== undefined && chunkIndex !== '') {
|
||||
const idx = Number.parseInt(chunkIndex, 10)
|
||||
if (Number.isInteger(idx) && idx >= 0) {
|
||||
chunkMatches.push({ index: idx, value })
|
||||
}
|
||||
} else {
|
||||
singleValues.push(value)
|
||||
}
|
||||
}
|
||||
match = sessionCookieRegex.exec(raw)
|
||||
}
|
||||
|
||||
const mergedChunkToken = mergeLatestChunkedSessionToken(chunkMatches)
|
||||
if (mergedChunkToken) {
|
||||
addUnique(sessionTokens, sessionSeen, mergedChunkToken)
|
||||
}
|
||||
|
||||
for (const value of singleValues) {
|
||||
addUnique(sessionTokens, sessionSeen, value)
|
||||
}
|
||||
}
|
||||
|
||||
function mergeChunkSegment(
|
||||
chunks: SessionCookieChunk[],
|
||||
requiredMaxIndex: number,
|
||||
requireComplete: boolean
|
||||
): string {
|
||||
if (chunks.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const byIndex = new Map<number, string>()
|
||||
for (const chunk of chunks) {
|
||||
byIndex.set(chunk.index, chunk.value)
|
||||
}
|
||||
|
||||
if (!byIndex.has(0)) {
|
||||
return ''
|
||||
}
|
||||
if (requireComplete) {
|
||||
for (let i = 0; i <= requiredMaxIndex; i++) {
|
||||
if (!byIndex.has(i)) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const orderedIndexes = Array.from(byIndex.keys()).sort((a, b) => a - b)
|
||||
return orderedIndexes.map((idx) => byIndex.get(idx) || '').join('')
|
||||
}
|
||||
|
||||
function mergeLatestChunkedSessionToken(chunks: SessionCookieChunk[]): string {
|
||||
if (chunks.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const requiredMaxIndex = chunks.reduce((max, chunk) => Math.max(max, chunk.index), 0)
|
||||
|
||||
const groupStarts: number[] = []
|
||||
chunks.forEach((chunk, idx) => {
|
||||
if (chunk.index === 0) {
|
||||
groupStarts.push(idx)
|
||||
}
|
||||
})
|
||||
|
||||
if (groupStarts.length === 0) {
|
||||
return mergeChunkSegment(chunks, requiredMaxIndex, false)
|
||||
}
|
||||
|
||||
for (let i = groupStarts.length - 1; i >= 0; i--) {
|
||||
const start = groupStarts[i]
|
||||
const end = i + 1 < groupStarts.length ? groupStarts[i + 1] : chunks.length
|
||||
const merged = mergeChunkSegment(chunks.slice(start, end), requiredMaxIndex, true)
|
||||
if (merged) {
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
return mergeChunkSegment(chunks, requiredMaxIndex, false)
|
||||
}
|
||||
|
||||
function collectPlainLines(
|
||||
raw: string,
|
||||
sessionTokens: string[],
|
||||
sessionSeen: Set<string>,
|
||||
accessTokens: string[],
|
||||
accessSeen: Set<string>
|
||||
): void {
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
for (const line of lines) {
|
||||
const normalized = line.toLowerCase()
|
||||
if (ignoredPlainLines.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
if (/^__secure-(next-auth|authjs)\.session-token(\.\d+)?=/i.test(line)) {
|
||||
continue
|
||||
}
|
||||
if (line.includes(';')) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*=/.test(line)) {
|
||||
const parts = line.split('=', 2)
|
||||
const key = parts[0]?.trim().toLowerCase()
|
||||
const value = parts[1]?.trim() || ''
|
||||
if (key && sessionKeyNames.has(key)) {
|
||||
addUnique(sessionTokens, sessionSeen, value)
|
||||
continue
|
||||
}
|
||||
if (key && accessKeyNames.has(key)) {
|
||||
addUnique(accessTokens, accessSeen, value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes('{') || line.includes('}') || line.includes(':') || /\s/.test(line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isLikelyJWT(line)) {
|
||||
addUnique(accessTokens, accessSeen, line)
|
||||
continue
|
||||
}
|
||||
addUnique(sessionTokens, sessionSeen, line)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSoraRawTokens(rawInput: string): ParsedSoraTokens {
|
||||
const raw = rawInput.trim()
|
||||
if (!raw) {
|
||||
return {
|
||||
sessionTokens: [],
|
||||
accessTokens: []
|
||||
}
|
||||
}
|
||||
|
||||
const sessionTokens: string[] = []
|
||||
const accessTokens: string[] = []
|
||||
const sessionSeen = new Set<string>()
|
||||
const accessSeen = new Set<string>()
|
||||
|
||||
collectFromJSONString(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
collectByRegex(raw, sessionRegexes, sessionTokens, sessionSeen)
|
||||
collectByRegex(raw, accessRegexes, accessTokens, accessSeen)
|
||||
collectFromSessionCookies(raw, sessionTokens, sessionSeen)
|
||||
collectPlainLines(raw, sessionTokens, sessionSeen, accessTokens, accessSeen)
|
||||
|
||||
return {
|
||||
sessionTokens,
|
||||
accessTokens
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
let globalStableObjectKeySeed = 0
|
||||
|
||||
/**
|
||||
* 为对象实例生成稳定 key(基于 WeakMap,不污染业务对象)
|
||||
*/
|
||||
export function createStableObjectKeyResolver<T extends object>(prefix = 'item') {
|
||||
const keyMap = new WeakMap<T, string>()
|
||||
|
||||
return (item: T): string => {
|
||||
const cached = keyMap.get(item)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const key = `${prefix}-${++globalStableObjectKeySeed}`
|
||||
keyMap.set(item, key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 验证并规范化 URL
|
||||
* 默认只接受绝对 URL(以 http:// 或 https:// 开头),可按需允许相对路径
|
||||
* @param value 用户输入的 URL
|
||||
* @returns 规范化后的 URL,如果无效则返回空字符串
|
||||
*/
|
||||
type SanitizeOptions = {
|
||||
allowRelative?: boolean
|
||||
allowDataUrl?: boolean
|
||||
}
|
||||
|
||||
export function sanitizeUrl(value: string, options: SanitizeOptions = {}): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (options.allowRelative && trimmed.startsWith('/') && !trimmed.startsWith('//')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 允许 data:image/ 开头的 data URL(仅限图片类型)
|
||||
if (options.allowDataUrl && trimmed.startsWith('data:image/')) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 只接受绝对 URL,不使用 base URL 来避免相对路径被解析为当前域名
|
||||
// 检查是否以 http:// 或 https:// 开头
|
||||
if (!trimmed.match(/^https?:\/\//i)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed)
|
||||
const protocol = parsed.protocol.toLowerCase()
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
return ''
|
||||
}
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export const TOKENS_PER_MILLION = 1_000_000
|
||||
|
||||
interface TokenPriceFormatOptions {
|
||||
fractionDigits?: number
|
||||
withCurrencySymbol?: boolean
|
||||
emptyValue?: string
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
export function calculateTokenUnitPrice(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined
|
||||
): number | null {
|
||||
if (!isFiniteNumber(cost) || !isFiniteNumber(tokens) || tokens <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return cost / tokens
|
||||
}
|
||||
|
||||
export function calculateTokenPricePerMillion(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined
|
||||
): number | null {
|
||||
const unitPrice = calculateTokenUnitPrice(cost, tokens)
|
||||
if (unitPrice == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return unitPrice * TOKENS_PER_MILLION
|
||||
}
|
||||
|
||||
export function formatTokenPricePerMillion(
|
||||
cost: number | null | undefined,
|
||||
tokens: number | null | undefined,
|
||||
options: TokenPriceFormatOptions = {}
|
||||
): string {
|
||||
const pricePerMillion = calculateTokenPricePerMillion(cost, tokens)
|
||||
if (pricePerMillion == null) {
|
||||
return options.emptyValue ?? '-'
|
||||
}
|
||||
|
||||
const fractionDigits = options.fractionDigits ?? 4
|
||||
const formatted = pricePerMillion.toFixed(fractionDigits)
|
||||
return options.withCurrencySymbol == false ? formatted : `$${formatted}`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { UsageRequestType } from '@/types'
|
||||
|
||||
export interface UsageRequestTypeLike {
|
||||
request_type?: string | null
|
||||
stream?: boolean | null
|
||||
openai_ws_mode?: boolean | null
|
||||
}
|
||||
|
||||
const VALID_REQUEST_TYPES = new Set<UsageRequestType>(['unknown', 'sync', 'stream', 'ws_v2'])
|
||||
|
||||
export const isUsageRequestType = (value: unknown): value is UsageRequestType => {
|
||||
return typeof value === 'string' && VALID_REQUEST_TYPES.has(value as UsageRequestType)
|
||||
}
|
||||
|
||||
export const resolveUsageRequestType = (value: UsageRequestTypeLike): UsageRequestType => {
|
||||
if (isUsageRequestType(value.request_type)) {
|
||||
return value.request_type
|
||||
}
|
||||
if (value.openai_ws_mode) {
|
||||
return 'ws_v2'
|
||||
}
|
||||
return value.stream ? 'stream' : 'sync'
|
||||
}
|
||||
|
||||
export const requestTypeToLegacyStream = (requestType?: UsageRequestType | null): boolean | null | undefined => {
|
||||
if (!requestType || requestType === 'unknown') {
|
||||
return null
|
||||
}
|
||||
if (requestType === 'sync') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function normalizeUsageServiceTier(serviceTier?: string | null): string | null {
|
||||
const value = serviceTier?.trim().toLowerCase()
|
||||
if (!value) return null
|
||||
if (value === 'fast') return 'priority'
|
||||
if (value === 'default' || value === 'standard') return 'standard'
|
||||
if (value === 'priority' || value === 'flex') return value
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatUsageServiceTier(serviceTier?: string | null): string {
|
||||
const normalized = normalizeUsageServiceTier(serviceTier)
|
||||
if (!normalized) return 'standard'
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function getUsageServiceTierLabel(
|
||||
serviceTier: string | null | undefined,
|
||||
translate: (key: string) => string,
|
||||
): string {
|
||||
const tier = formatUsageServiceTier(serviceTier)
|
||||
if (tier === 'priority') return translate('usage.serviceTierPriority')
|
||||
if (tier === 'flex') return translate('usage.serviceTierFlex')
|
||||
if (tier === 'standard') return translate('usage.serviceTierStandard')
|
||||
return tier
|
||||
}
|
||||
Reference in New Issue
Block a user