Compare commits

...

8 Commits

18 changed files with 1617 additions and 254 deletions
@@ -9,6 +9,7 @@ import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@
import type { AuthFileItem } from '@/types/authFile';
import type { CredentialInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetails,
extractLatencyMs,
@@ -131,7 +132,7 @@ export function RequestEventsDetailsCard({
const timestampMs =
typeof detail.__timestampMs === 'number' && detail.__timestampMs > 0
? detail.__timestampMs
: Date.parse(timestamp);
: parseTimestampMs(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
+2 -1
View File
@@ -9,6 +9,7 @@ import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconVertex from '@/assets/icons/vertex.svg';
import type { AuthFileItem } from '@/types';
import { parseTimestamp } from '@/utils/timestamp';
import {
normalizeAuthIndex,
normalizeUsageSourceId,
@@ -279,7 +280,7 @@ export const formatModified = (item: AuthFileItem): string => {
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
: parseTimestamp(raw) ?? new Date(String(raw));
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
+2
View File
@@ -5,6 +5,7 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zhCN from './locales/zh-CN.json';
import zhTW from './locales/zh-TW.json';
import en from './locales/en.json';
import ru from './locales/ru.json';
import { getInitialLanguage } from '@/utils/language';
@@ -12,6 +13,7 @@ import { getInitialLanguage } from '@/utils/language';
i18n.use(initReactI18next).init({
resources: {
'zh-CN': { translation: zhCN },
'zh-TW': { translation: zhTW },
en: { translation: en },
ru: { translation: ru }
},
+1 -28
View File
@@ -891,17 +891,6 @@
"kimi_oauth_status_error": "Authentication failed:",
"kimi_oauth_start_error": "Failed to start Kimi OAuth:",
"kimi_oauth_polling_error": "Failed to check authentication status:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Start Qwen Login",
"qwen_oauth_hint": "Login to Qwen service through device authorization flow, automatically obtain and save authentication files.",
"qwen_oauth_url_label": "Authorization URL:",
"qwen_open_link": "Open Link",
"qwen_copy_link": "Copy Link",
"qwen_oauth_status_waiting": "Waiting for authentication...",
"qwen_oauth_status_success": "Authentication successful!",
"qwen_oauth_status_error": "Authentication failed:",
"qwen_oauth_start_error": "Failed to start Qwen OAuth:",
"qwen_oauth_polling_error": "Failed to check authentication status:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.",
@@ -924,23 +913,6 @@
"iflow_oauth_status_error": "Authentication failed:",
"iflow_oauth_start_error": "Failed to start iFlow OAuth:",
"iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login",
"iflow_cookie_status_success": "Cookie login succeeded and credentials are saved.",
"iflow_cookie_status_error": "Cookie login failed:",
"iflow_cookie_status_duplicate": "Duplicate config:",
"iflow_cookie_start_error": "Failed to submit cookie login:",
"iflow_cookie_config_duplicate": "A config file already exists (duplicate). Remove the existing file and try again if you want to re-save it.",
"iflow_cookie_required": "Please provide the Cookie value first.",
"iflow_cookie_result_title": "Cookie Login Result",
"iflow_cookie_result_email": "Account",
"iflow_cookie_result_expired": "Expires At",
"iflow_cookie_result_path": "Saved Path",
"iflow_cookie_result_type": "Type",
"remote_access_disabled": "This login method is not available for remote access. Please access from localhost."
},
"usage_stats": {
@@ -1487,6 +1459,7 @@
"language": {
"switch": "Language",
"chinese": "中文",
"chinese_tw": "Traditional Chinese (Taiwan)",
"english": "English",
"russian": "Русский"
},
+1 -28
View File
@@ -888,17 +888,6 @@
"kimi_oauth_status_error": "Ошибка аутентификации:",
"kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:",
"kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "Начать вход Qwen",
"qwen_oauth_hint": "Выполните вход в сервис Qwen через поток авторизации устройства и автоматически получите/сохраните файлы авторизации.",
"qwen_oauth_url_label": "URL авторизации:",
"qwen_open_link": "Открыть ссылку",
"qwen_copy_link": "Скопировать ссылку",
"qwen_oauth_status_waiting": "Ожидание аутентификации...",
"qwen_oauth_status_success": "Аутентификация успешна!",
"qwen_oauth_status_error": "Ошибка аутентификации:",
"qwen_oauth_start_error": "Не удалось запустить Qwen OAuth:",
"qwen_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"oauth_callback_label": "Callback URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.",
@@ -921,23 +910,6 @@
"iflow_oauth_status_error": "Ошибка аутентификации:",
"iflow_oauth_start_error": "Не удалось запустить iFlow OAuth:",
"iflow_oauth_polling_error": "Не удалось проверить статус аутентификации:",
"iflow_cookie_title": "Вход iFlow по cookie",
"iflow_cookie_label": "Значение cookie:",
"iflow_cookie_placeholder": "Введите значение BXAuth, начиная с BXAuth=",
"iflow_cookie_hint": "Отправьте существующий cookie, чтобы завершить вход без открытия ссылки авторизации; файл учётных данных будет сохранён автоматически.",
"iflow_cookie_key_hint": "Примечание: сначала создайте ключ на платформе.",
"iflow_cookie_button": "Отправить вход по cookie",
"iflow_cookie_status_success": "Вход по cookie выполнен, учётные данные сохранены.",
"iflow_cookie_status_error": "Ошибка входа по cookie:",
"iflow_cookie_status_duplicate": "Дублирующая конфигурация:",
"iflow_cookie_start_error": "Не удалось отправить вход по cookie:",
"iflow_cookie_config_duplicate": "Такая конфигурация уже существует. Удалите файл и повторите, если хотите перезаписать.",
"iflow_cookie_required": "Сначала укажите значение cookie.",
"iflow_cookie_result_title": "Результат входа по cookie",
"iflow_cookie_result_email": "Аккаунт",
"iflow_cookie_result_expired": "Истекает",
"iflow_cookie_result_path": "Путь сохранения",
"iflow_cookie_result_type": "Тип",
"remote_access_disabled": "Этот способ входа недоступен при удалённом доступе. Подключитесь с localhost."
},
"usage_stats": {
@@ -1486,6 +1458,7 @@
"language": {
"switch": "Язык",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
+1 -28
View File
@@ -891,17 +891,6 @@
"kimi_oauth_status_error": "认证失败:",
"kimi_oauth_start_error": "启动 Kimi OAuth 失败:",
"kimi_oauth_polling_error": "检查认证状态失败:",
"qwen_oauth_title": "Qwen OAuth",
"qwen_oauth_button": "开始 Qwen 登录",
"qwen_oauth_hint": "通过设备授权流程登录 Qwen 服务,自动获取并保存认证文件。",
"qwen_oauth_url_label": "授权链接:",
"qwen_open_link": "打开链接",
"qwen_copy_link": "复制链接",
"qwen_oauth_status_waiting": "等待认证中...",
"qwen_oauth_status_success": "认证成功!",
"qwen_oauth_status_error": "认证失败:",
"qwen_oauth_start_error": "启动 Qwen OAuth 失败:",
"qwen_oauth_polling_error": "检查认证状态失败:",
"oauth_callback_label": "回调 URL",
"oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...",
"oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。",
@@ -924,23 +913,6 @@
"iflow_oauth_status_error": "认证失败:",
"iflow_oauth_start_error": "启动 iFlow OAuth 失败:",
"iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录",
"iflow_cookie_status_success": "Cookie 登录成功,凭据已保存。",
"iflow_cookie_status_error": "Cookie 登录失败:",
"iflow_cookie_status_duplicate": "配置文件重复:",
"iflow_cookie_start_error": "提交 Cookie 登录失败:",
"iflow_cookie_config_duplicate": "检测到配置文件已存在(重复),如需重新保存请先删除原文件后重试。",
"iflow_cookie_required": "请先填写 Cookie 内容",
"iflow_cookie_result_title": "Cookie 登录结果",
"iflow_cookie_result_email": "账号",
"iflow_cookie_result_expired": "过期时间",
"iflow_cookie_result_path": "保存路径",
"iflow_cookie_result_type": "类型",
"remote_access_disabled": "远程访问不支持此登录方式,请从本地 (localhost) 访问"
},
"usage_stats": {
@@ -1487,6 +1459,7 @@
"language": {
"switch": "语言",
"chinese": "中文",
"chinese_tw": "繁體中文(台灣)",
"english": "English",
"russian": "Русский"
},
File diff suppressed because it is too large Load Diff
+2 -128
View File
@@ -4,7 +4,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useNotificationStore, useThemeStore } from '@/stores';
import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth';
import { oauthApi, type OAuthProvider } from '@/services/api/oauth';
import { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import { copyToClipboard } from '@/utils/clipboard';
import styles from './OAuthPage.module.scss';
@@ -14,8 +14,6 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconKimiLight from '@/assets/icons/kimi-light.svg';
import iconKimiDark from '@/assets/icons/kimi-dark.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState {
@@ -32,14 +30,6 @@ interface ProviderState {
callbackError?: string;
}
interface IFlowCookieState {
cookie: string;
loading: boolean;
result?: IFlowCookieAuthResponse;
error?: string;
errorType?: 'error' | 'warning';
}
interface VertexImportResult {
projectId?: string;
email?: string;
@@ -76,8 +66,7 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
{ id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity },
{ id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini },
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } },
{ id: 'qwen', titleKey: 'auth_login.qwen_oauth_title', hintKey: 'auth_login.qwen_oauth_hint', urlLabelKey: 'auth_login.qwen_oauth_url_label', icon: iconQwen }
{ id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }
];
const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
@@ -94,7 +83,6 @@ export function OAuthPage() {
const { showNotification } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [states, setStates] = useState<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
const [iflowCookie, setIflowCookie] = useState<IFlowCookieState>({ cookie: '', loading: false });
const [vertexState, setVertexState] = useState<VertexImportState>({
fileName: '',
location: '',
@@ -235,49 +223,6 @@ export function OAuthPage() {
}
};
const submitIflowCookie = async () => {
const cookie = iflowCookie.cookie.trim();
if (!cookie) {
showNotification(t('auth_login.iflow_cookie_required'), 'warning');
return;
}
setIflowCookie((prev) => ({
...prev,
loading: true,
error: undefined,
errorType: undefined,
result: undefined
}));
try {
const res = await oauthApi.iflowCookieAuth(cookie);
if (res.status === 'ok') {
setIflowCookie((prev) => ({ ...prev, loading: false, result: res }));
showNotification(t('auth_login.iflow_cookie_status_success'), 'success');
} else {
setIflowCookie((prev) => ({
...prev,
loading: false,
error: res.error,
errorType: 'error'
}));
showNotification(`${t('auth_login.iflow_cookie_status_error')} ${res.error || ''}`, 'error');
}
} catch (err: unknown) {
if (getErrorStatus(err) === 409) {
const message = t('auth_login.iflow_cookie_config_duplicate');
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'warning' }));
showNotification(message, 'warning');
return;
}
const message = getErrorMessage(err);
setIflowCookie((prev) => ({ ...prev, loading: false, error: message, errorType: 'error' }));
showNotification(
`${t('auth_login.iflow_cookie_start_error')}${message ? ` ${message}` : ''}`,
'error'
);
}
};
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
@@ -542,77 +487,6 @@ export function OAuthPage() {
)}
</div>
</Card>
{/* iFlow Cookie 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconIflow} alt="" className={styles.cardTitleIcon} />
{t('auth_login.iflow_cookie_title')}
</span>
}
extra={
<Button onClick={submitIflowCookie} loading={iflowCookie.loading}>
{t('auth_login.iflow_cookie_button')}
</Button>
}
>
<div className={styles.cardContent}>
<div className={styles.cardHint}>{t('auth_login.iflow_cookie_hint')}</div>
<div className={styles.cardHintSecondary}>
{t('auth_login.iflow_cookie_key_hint')}
</div>
<div className={styles.formItem}>
<label className={styles.formItemLabel}>{t('auth_login.iflow_cookie_label')}</label>
<Input
value={iflowCookie.cookie}
onChange={(e) => setIflowCookie((prev) => ({ ...prev, cookie: e.target.value }))}
placeholder={t('auth_login.iflow_cookie_placeholder')}
/>
</div>
{iflowCookie.error && (
<div
className={`status-badge ${iflowCookie.errorType === 'warning' ? 'warning' : 'error'}`}
>
{iflowCookie.errorType === 'warning'
? t('auth_login.iflow_cookie_status_duplicate')
: t('auth_login.iflow_cookie_status_error')}{' '}
{iflowCookie.error}
</div>
)}
{iflowCookie.result && iflowCookie.result.status === 'ok' && (
<div className={styles.connectionBox}>
<div className={styles.connectionLabel}>{t('auth_login.iflow_cookie_result_title')}</div>
<div className={styles.keyValueList}>
{iflowCookie.result.email && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_email')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.email}</span>
</div>
)}
{iflowCookie.result.expired && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_expired')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.expired}</span>
</div>
)}
{iflowCookie.result.saved_path && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_path')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.saved_path}</span>
</div>
)}
{iflowCookie.result.type && (
<div className={styles.keyValueItem}>
<span className={styles.keyValueKey}>{t('auth_login.iflow_cookie_result_type')}</span>
<span className={styles.keyValueValue}>{iflowCookie.result.type}</span>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
+2 -1
View File
@@ -5,6 +5,7 @@ import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { AuthFileItem, Config } from '@/types';
import type { CredentialInfo, SourceInfo } from '@/types/sourceInfo';
import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver';
import { parseTimestampMs } from '@/utils/timestamp';
import {
collectUsageDetailsWithEndpoint,
normalizeAuthIndex,
@@ -183,7 +184,7 @@ export function useTraceResolver(options: UseTraceResolverOptions): UseTraceReso
if (!logPath) return [];
const logTimestampMs = traceLogLine.timestamp
? Date.parse(traceLogLine.timestamp)
? parseTimestampMs(traceLogLine.timestamp)
: Number.NaN;
// Step 1: filter by path match
+2 -1
View File
@@ -5,6 +5,7 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelAliasEntry } from '@/types';
import { parseTimestampMs } from '@/utils/timestamp';
type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
@@ -185,7 +186,7 @@ const readDateField = (entry: AuthFileEntry): number => {
if (Number.isFinite(asNumber)) {
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
}
const parsed = Date.parse(trimmed);
const parsed = parseTimestampMs(trimmed);
if (!Number.isNaN(parsed)) {
return parsed;
}
+2 -16
View File
@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
| 'kimi';
export interface OAuthStartResponse {
url: string;
@@ -21,15 +20,6 @@ export interface OAuthCallbackResponse {
status: 'ok';
}
export interface IFlowCookieAuthResponse {
status: 'ok' | 'error';
error?: string;
saved_path?: string;
email?: string;
expired?: string;
type?: string;
}
const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli'];
const CALLBACK_PROVIDER_MAP: Partial<Record<OAuthProvider, string>> = {
'gemini-cli': 'gemini'
@@ -60,9 +50,5 @@ export const oauthApi = {
provider: callbackProvider,
redirect_url: redirectUrl
});
},
/** iFlow cookie 认证 */
iflowCookieAuth: (cookie: string) =>
apiClient.post<IFlowCookieAuthResponse>('/iflow-auth-url', { cookie })
}
};
+1 -1
View File
@@ -4,7 +4,7 @@
export type Theme = 'light' | 'white' | 'dark' | 'auto';
export type Language = 'zh-CN' | 'en' | 'ru';
export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ru';
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
+1 -2
View File
@@ -9,8 +9,7 @@ export type OAuthProvider =
| 'anthropic'
| 'antigravity'
| 'gemini-cli'
| 'kimi'
| 'qwen';
| 'kimi';
// OAuth 流程状态
export interface OAuthFlow {
+4 -5
View File
@@ -40,9 +40,10 @@ export const STORAGE_KEY_SIDEBAR = 'cli-proxy-sidebar-collapsed';
export const STORAGE_KEY_AUTH_FILES_PAGE_SIZE = 'cli-proxy-auth-files-page-size';
// 语言配置
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'en', 'ru'] as const);
export const LANGUAGE_ORDER = defineLanguageOrder(['zh-CN', 'zh-TW', 'en', 'ru'] as const);
export const LANGUAGE_LABEL_KEYS: Record<Language, string> = {
'zh-CN': 'language.chinese',
'zh-TW': 'language.chinese_tw',
en: 'language.english',
ru: 'language.russian'
};
@@ -57,16 +58,14 @@ export const OAUTH_CARD_IDS = [
'anthropic-oauth-card',
'antigravity-oauth-card',
'gemini-cli-oauth-card',
'kimi-oauth-card',
'qwen-oauth-card'
'kimi-oauth-card'
];
export const OAUTH_PROVIDERS = {
CODEX: 'codex',
ANTHROPIC: 'anthropic',
ANTIGRAVITY: 'antigravity',
GEMINI_CLI: 'gemini-cli',
KIMI: 'kimi',
QWEN: 'qwen'
KIMI: 'kimi'
} as const;
// API 端点
+4 -2
View File
@@ -1,3 +1,5 @@
import { parseTimestamp } from './timestamp';
/**
* 格式化工具函数
* 从原项目 src/utils/string.js 迁移
@@ -47,7 +49,7 @@ export function formatFileSize(bytes: number): string {
* 格式化日期时间
*/
export function formatDateTime(date: string | Date, locale?: string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const d = typeof date === 'string' ? parseTimestamp(date) ?? new Date(date) : date;
if (isNaN(d.getTime())) {
return 'Invalid Date';
@@ -73,7 +75,7 @@ export function formatUnixTimestamp(value: unknown, locale?: string): string {
const asNumber = typeof value === 'number' ? value : Number(value);
const date = (() => {
if (!Number.isFinite(asNumber) || Number.isNaN(asNumber)) {
return new Date(String(value));
return parseTimestamp(value) ?? new Date(String(value));
}
const abs = Math.abs(asNumber);
+3
View File
@@ -1,6 +1,8 @@
import type { Language } from '@/types';
import { STORAGE_KEY_LANGUAGE, SUPPORTED_LANGUAGES } from '@/utils/constants';
const TRADITIONAL_CHINESE_PREFIXES = ['zh-tw', 'zh-hk', 'zh-mo', 'zh-hant'] as const;
export const isSupportedLanguage = (value: string): value is Language =>
SUPPORTED_LANGUAGES.includes(value as Language);
@@ -40,6 +42,7 @@ const getBrowserLanguage = (): Language => {
}
const raw = navigator.languages?.[0] || navigator.language || 'zh-CN';
const lower = raw.toLowerCase();
if (TRADITIONAL_CHINESE_PREFIXES.some((prefix) => lower.startsWith(prefix))) return 'zh-TW';
if (lower.startsWith('zh')) return 'zh-CN';
if (lower.startsWith('ru')) return 'ru';
return 'en';
+61
View File
@@ -0,0 +1,61 @@
const RFC3339_HIGH_PRECISION_REGEX =
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/i;
/**
* Some browsers mis-handle RFC3339 timestamps that include sub-millisecond
* precision. Normalize them to millisecond precision before parsing.
*/
export function normalizeTimestampForDateParse(value: string): string {
const trimmed = value.trim();
if (!trimmed) return '';
const match = trimmed.match(RFC3339_HIGH_PRECISION_REGEX);
if (!match) return trimmed;
const [, base, , fractionDigits = '', timezone = ''] = match;
if (fractionDigits.length <= 3) {
return trimmed;
}
return `${base}.${fractionDigits.slice(0, 3)}${timezone}`;
}
export function parseTimestampMs(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (value instanceof Date) {
return value.getTime();
}
if (typeof value !== 'string') {
return Number.NaN;
}
const trimmed = value.trim();
if (!trimmed) {
return Number.NaN;
}
const normalized = normalizeTimestampForDateParse(trimmed);
const normalizedParsed = Date.parse(normalized);
if (!Number.isNaN(normalizedParsed)) {
return normalizedParsed;
}
if (normalized !== trimmed) {
const originalParsed = Date.parse(trimmed);
if (!Number.isNaN(originalParsed)) {
return originalParsed;
}
}
return Number.NaN;
}
export function parseTimestamp(value: unknown): Date | null {
const timestampMs = parseTimestampMs(value);
if (!Number.isFinite(timestampMs)) {
return null;
}
return new Date(timestampMs);
}
+13 -12
View File
@@ -13,6 +13,7 @@ import {
finalizeLatencyStats,
} from './usage/latency';
import { maskApiKey } from './format';
import { parseTimestampMs } from './timestamp';
export type { DurationFormatOptions, LatencyStats } from './usage/latency';
export {
@@ -195,7 +196,7 @@ export function filterUsageByTimeRange<T>(
if (!detailRecord || typeof detailRecord.timestamp !== 'string') {
return;
}
const timestamp = Date.parse(detailRecord.timestamp);
const timestamp = parseTimestampMs(detailRecord.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > nowMs) {
return;
}
@@ -545,7 +546,7 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const timestampMs = parseTimestampMs(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
const latencyMs = extractLatencyMs(detailRaw);
details.push({
@@ -618,7 +619,7 @@ export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetail
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const timestamp = detailRaw.timestamp;
const timestampMs = Date.parse(timestamp);
const timestampMs = parseTimestampMs(timestamp);
const tokensRaw = isRecord(detailRaw.tokens) ? detailRaw.tokens : {};
const latencyMs = extractLatencyMs(detailRaw);
details.push({
@@ -721,7 +722,7 @@ export function calculateRecentPerMinuteRates(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp < windowStart || timestamp > now) {
return;
}
@@ -1131,7 +1132,7 @@ export function buildHourlySeriesByModel(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1190,7 +1191,7 @@ export function buildDailySeriesByModel(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) {
return;
}
@@ -1416,7 +1417,7 @@ export function calculateStatusBarData(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (
!Number.isFinite(timestamp) ||
timestamp <= 0 ||
@@ -1524,7 +1525,7 @@ export function calculateServiceHealthData(usageDetails: UsageDetail[]): Service
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (
!Number.isFinite(timestamp) ||
timestamp <= 0 ||
@@ -1734,7 +1735,7 @@ export function buildHourlyTokenBreakdown(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1776,7 +1777,7 @@ export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeri
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
@@ -1853,7 +1854,7 @@ export function buildHourlyCostSeries(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
@@ -1888,7 +1889,7 @@ export function buildDailyCostSeries(
const timestamp =
typeof detail.__timestampMs === 'number'
? detail.__timestampMs
: Date.parse(detail.timestamp);
: parseTimestampMs(detail.timestamp);
if (!Number.isFinite(timestamp) || timestamp <= 0) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;