Compare commits

...

9 Commits

21 changed files with 1171 additions and 30 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24px" height="24px"><path d="M20,13.89A.77.77,0,0,0,19,13.73l-7,5.14v.22a.72.72,0,1,1,0,1.43v0a.74.74,0,0,0,.45-.15l7.41-5.47A.76.76,0,0,0,20,13.89Z" style="fill:#669df6"/><path d="M12,20.52a.72.72,0,0,1,0-1.43h0v-.22L5,13.73a.76.76,0,0,0-1,.16.74.74,0,0,0,.16,1l7.41,5.47a.73.73,0,0,0,.44.15v0Z" style="fill:#aecbfa"/><path d="M12,18.34a1.47,1.47,0,1,0,1.47,1.47A1.47,1.47,0,0,0,12,18.34Zm0,2.18a.72.72,0,1,1,.72-.71A.71.71,0,0,1,12,20.52Z" style="fill:#4285f4"/><path d="M6,6.11a.76.76,0,0,1-.75-.75V3.48a.76.76,0,1,1,1.51,0V5.36A.76.76,0,0,1,6,6.11Z" style="fill:#aecbfa"/><circle cx="5.98" cy="12" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="9.79" r="0.76" style="fill:#aecbfa"/><circle cx="5.98" cy="7.57" r="0.76" style="fill:#aecbfa"/><path d="M18,8.31a.76.76,0,0,1-.75-.76V5.67a.75.75,0,1,1,1.5,0V7.55A.75.75,0,0,1,18,8.31Z" style="fill:#4285f4"/><circle cx="18.02" cy="12.01" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="9.76" r="0.76" style="fill:#4285f4"/><circle cx="18.02" cy="3.48" r="0.76" style="fill:#4285f4"/><path d="M12,15a.76.76,0,0,1-.75-.75V12.34a.76.76,0,0,1,1.51,0v1.89A.76.76,0,0,1,12,15Z" style="fill:#669df6"/><circle cx="12" cy="16.45" r="0.76" style="fill:#669df6"/><circle cx="12" cy="10.14" r="0.76" style="fill:#669df6"/><circle cx="12" cy="7.92" r="0.76" style="fill:#669df6"/><path d="M15,10.54a.76.76,0,0,1-.75-.75V7.91a.76.76,0,1,1,1.51,0V9.79A.76.76,0,0,1,15,10.54Z" style="fill:#4285f4"/><circle cx="15.01" cy="5.69" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="14.19" r="0.76" style="fill:#4285f4"/><circle cx="15.01" cy="11.97" r="0.76" style="fill:#4285f4"/><circle cx="8.99" cy="14.19" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="7.92" r="0.76" style="fill:#aecbfa"/><circle cx="8.99" cy="5.69" r="0.76" style="fill:#aecbfa"/><path d="M9,12.73A.76.76,0,0,1,8.24,12V10.1a.75.75,0,1,1,1.5,0V12A.75.75,0,0,1,9,12.73Z" style="fill:#aecbfa"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -7,7 +7,7 @@ import {
useRef,
useState,
} from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -173,6 +173,7 @@ const compareVersions = (latest?: string | null, current?: string | null) => {
export function MainLayout() {
const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore();
const location = useLocation();
const apiBase = useAuthStore((state) => state.apiBase);
const serverVersion = useAuthStore((state) => state.serverVersion);
@@ -207,6 +208,7 @@ export function MainLayout() {
const requestLogEnabled = config?.requestLog ?? false;
const requestLogDirty = requestLogDraft !== requestLogEnabled;
const canEditRequestLog = connectionStatus === 'connected' && Boolean(config);
const isLogsPage = location.pathname.startsWith('/logs');
// 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动
useLayoutEffect(() => {
@@ -503,8 +505,8 @@ export function MainLayout() {
</div>
</aside>
<div className="content">
<main className="main-content">
<div className={`content${isLogsPage ? ' content-logs' : ''}`}>
<main className={`main-content${isLogsPage ? ' main-content-logs' : ''}`}>
<Outlet />
</main>

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "This model is excluded by OAuth"
},
"vertex_import": {
"title": "Vertex AI Credential Import",
"title": "Vertex JSON Login",
"description": "Upload a Google service account JSON to store it as auth-dir/vertex-<project>.json using the same rules as the CLI vertex-import helper.",
"location_label": "Region (optional)",
"location_placeholder": "us-central1",
@@ -534,6 +534,11 @@
"by_hour": "By Hour",
"by_day": "By Day",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
"chart_line_label_1": "Line 1",
"chart_line_label_2": "Line 2",
"chart_line_label_3": "Line 3",
@@ -596,6 +601,11 @@
"error_logs_modified": "Last modified",
"error_logs_download": "Download",
"error_log_download_success": "Error log downloaded successfully",
"request_log_download_title": "Download Request Log",
"request_log_download_confirm": "Download request log for ID {{id}}?",
"request_log_download_success": "Request log downloaded successfully",
"action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.",
"action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.",
"empty_title": "No Logs Available",
"empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here",
"log_content": "Log Content",

View File

@@ -358,7 +358,7 @@
"models_excluded_hint": "此模型已被 OAuth 排除"
},
"vertex_import": {
"title": "Vertex AI 凭证导入",
"title": "Vertex JSON 登录",
"description": "上传 Google 服务账号 JSON使用 CLI vertex-import 同步规则写入 auth-dir/vertex-<project>.json。",
"location_label": "目标区域 (可选)",
"location_placeholder": "us-central1",
@@ -534,6 +534,11 @@
"by_hour": "按小时",
"by_day": "按天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
"chart_line_label_1": "曲线 1",
"chart_line_label_2": "曲线 2",
"chart_line_label_3": "曲线 3",
@@ -596,6 +601,11 @@
"error_logs_modified": "最后修改",
"error_logs_download": "下载",
"error_log_download_success": "错误日志下载成功",
"request_log_download_title": "下载报文",
"request_log_download_confirm": "是否要下载id为{{id}}的报文?",
"request_log_download_success": "报文下载成功",
"action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。",
"action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。",
"empty_title": "暂无日志记录",
"empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里",
"log_content": "日志内容",

View File

@@ -397,6 +397,79 @@
line-height: 1.5;
}
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
// 暗色主题适配
:global([data-theme='dark']) {
.headerBadge {
@@ -436,4 +509,23 @@
.apiKeyEntryIndex {
background: var(--primary-color);
}
.statusBlockIdle {
background-color: var(--border-primary, #374151);
}
.statusRateHigh {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.statusRateMedium {
background: rgba(251, 191, 36, 0.2);
color: #fde68a;
}
.statusRateLow {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
}

View File

@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -24,7 +25,8 @@ import type {
AmpcodeConfig,
AmpcodeModelMapping,
} from '@/types';
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import type { ModelInfo } from '@/utils/models';
import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { maskApiKey } from '@/utils/format';
@@ -202,6 +204,8 @@ export function AiProvidersPage() {
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const [modal, setModal] = useState<ProviderModal | null>(null);
@@ -273,13 +277,23 @@ export function AiProvidersPage() {
[openaiForm.modelEntries]
);
// 加载 key 统计
// 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => {
// 防止重复请求
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const stats = await usageApi.getKeyStats();
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
// 收集 usage 明细用于状态栏
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
@@ -311,6 +325,9 @@ export function AiProvidersPage() {
loadKeyStats();
}, [loadKeyStats]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
useEffect(() => {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
@@ -1090,6 +1107,108 @@ export function AiProvidersPage() {
);
};
// 预计算所有 apiKey 的状态栏数据(避免每次渲染重复计算)
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
// 收集所有需要计算的 apiKey
const allApiKeys = new Set<string>();
geminiKeys.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
codexConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
claudeConfigs.forEach((k) => k.apiKey && allApiKeys.add(k.apiKey));
openaiProviders.forEach((p) => {
(p.apiKeyEntries || []).forEach((e) => e.apiKey && allApiKeys.add(e.apiKey));
});
// 预计算每个 apiKey 的状态数据
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [usageDetails, geminiKeys, codexConfigs, claudeConfigs, openaiProviders]);
// 预计算 OpenAI 提供商的汇总状态栏数据
const openaiStatusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
openaiProviders.forEach((provider) => {
const allKeys = (provider.apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean);
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
cache.set(provider.name, calculateStatusBarData(filteredDetails));
});
return cache;
}, [usageDetails, openaiProviders]);
// 渲染状态监测栏
const renderStatusBar = (apiKey: string) => {
const statusData = statusBarCache.get(apiKey) || calculateStatusBarData([], apiKey);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
// 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey
const renderOpenAIStatusBar = (providerName: string) => {
const statusData = openaiStatusBarCache.get(providerName) || calculateStatusBarData([]);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
const renderList = <T,>(
items: T[],
keyField: (item: T) => string,
@@ -1254,6 +1373,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure}
</span>
</div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment>
);
},
@@ -1370,6 +1491,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure}
</span>
</div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment>
);
},
@@ -1502,6 +1625,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure}
</span>
</div>
{/* 状态监测栏 */}
{renderStatusBar(item.apiKey)}
</Fragment>
);
},
@@ -1721,6 +1846,8 @@ export function AiProvidersPage() {
{t('stats.failure')}: {stats.failure}
</span>
</div>
{/* 状态监测栏(汇总) */}
{renderOpenAIStatusBar(item.name)}
</Fragment>
);
},

View File

@@ -250,6 +250,78 @@
border-color: var(--failure-badge-border, #fca5a5);
}
// 状态监测栏
.statusBar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
max-width: 280px;
}
.statusBlocks {
display: flex;
gap: 2px;
flex: 1;
min-width: 180px;
}
.statusBlock {
flex: 1;
height: 8px;
border-radius: 2px;
min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease;
&:hover {
transform: scaleY(1.5);
opacity: 0.85;
}
}
.statusBlockSuccess {
background-color: var(--success-color, #22c55e);
}
.statusBlockFailure {
background-color: var(--danger-color, #ef4444);
}
.statusBlockMixed {
background-color: var(--warning-color, #f59e0b);
}
.statusBlockIdle {
background-color: var(--border-secondary, #e5e7eb);
}
.statusRate {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-tertiary);
}
.statusRateHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
}
.statusRateMedium {
color: var(--warning-text, #92400e);
background: var(--warning-bg, #fef3c7);
}
.statusRateLow {
color: var(--failure-badge-text, #991b1b);
background: var(--failure-badge-bg, #fee2e2);
}
.cardActions {
display: flex;
gap: $spacing-xs;

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useInterval } from '@/hooks/useInterval';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
@@ -11,7 +12,8 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types';
import type { KeyStats, KeyStatBucket } from '@/utils/usage';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import styles from './AuthFilesPage.module.scss';
@@ -143,6 +145,7 @@ export function AuthFilesPage() {
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
// 详情弹窗相关
const [detailModalOpen, setDetailModalOpen] = useState(false);
@@ -164,6 +167,7 @@ export function AuthFilesPage() {
const [savingExcluded, setSavingExcluded] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadingKeyStatsRef = useRef(false);
const excludedUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected';
@@ -195,13 +199,23 @@ export function AuthFilesPage() {
}
}, [t]);
// 加载 key 统计
// 加载 key 统计和 usage 明细API 层已有60秒超时
const loadKeyStats = useCallback(async () => {
// 防止重复请求
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const stats = await usageApi.getKeyStats();
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
// 收集 usage 明细用于状态栏
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
@@ -237,6 +251,9 @@ export function AuthFilesPage() {
loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]);
// 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000);
// 提取所有存在的类型
const existingTypes = useMemo(() => {
const types = new Set<string>(['all']);
@@ -570,6 +587,65 @@ export function AuthFilesPage() {
</div>
);
// 预计算所有认证文件的状态栏数据(避免每次渲染重复计算)
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
if (authIndexKey) {
// 过滤出属于该认证文件的 usage 明细
const filteredDetails = usageDetails.filter((detail) => {
const detailAuthIndex = normalizeAuthIndexValue(detail.auth_index);
return detailAuthIndex !== null && detailAuthIndex === authIndexKey;
});
cache.set(authIndexKey, calculateStatusBarData(filteredDetails));
}
});
return cache;
}, [usageDetails, files]);
// 渲染状态监测栏
const renderStatusBar = (item: AuthFileItem) => {
// 认证文件使用 authIndex 来匹配 usage 数据
const rawAuthIndex = item['auth_index'] ?? item.authIndex;
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const hasData = statusData.totalSuccess + statusData.totalFailure > 0;
const rateClass = !hasData
? ''
: statusData.successRate >= 90
? styles.statusRateHigh
: statusData.successRate >= 50
? styles.statusRateMedium
: styles.statusRateLow;
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
);
};
// 渲染单个认证文件卡片
const renderFileCard = (item: AuthFileItem) => {
const fileStats = resolveAuthFileStats(item, keyStats);
@@ -606,6 +682,9 @@ export function AuthFilesPage() {
</span>
</div>
{/* 状态监测栏 */}
{renderStatusBar(item)}
<div className={styles.cardActions}>
{isRuntimeOnly ? (
<div className={styles.virtualBadge}>{t('auth_files.type_virtual') || '虚拟认证文件'}</div>

View File

@@ -242,7 +242,11 @@ export function DashboardPage() {
</div>
<div className={styles.connectionInfo}>
<span className={styles.serverUrl}>{apiBase || '-'}</span>
{serverVersion && <span className={styles.serverVersion}>v{serverVersion}</span>}
{serverVersion && (
<span className={styles.serverVersion}>
v{serverVersion.trim().replace(/^[vV]+/, '')}
</span>
)}
{serverBuildDate && (
<span className={styles.buildDate}>
{new Date(serverBuildDate).toLocaleDateString(i18n.language)}

View File

@@ -2,11 +2,15 @@
.container {
width: 100%;
height: 100%;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
@include mobile {
min-height: auto;
overflow: visible;
}
}
.pageTitle {
@@ -53,6 +57,11 @@
gap: $spacing-lg;
flex: 1;
min-height: 0;
@include mobile {
gap: $spacing-md;
min-height: auto;
}
}
.logCard {
@@ -61,6 +70,12 @@
flex: 1;
min-height: 0;
overflow: hidden;
@include mobile {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
}
.toolbar {
@@ -87,6 +102,11 @@
:global(.form-group) {
margin: 0;
}
@include mobile {
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
}
.searchWrapper {
@@ -161,13 +181,26 @@
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
flex: 0 0 auto;
height: 480px;
flex: 1 1 auto;
min-height: 280px;
max-height: calc(100vh - 320px);
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
overscroll-behavior: contain;
@include tablet {
min-height: 240px;
max-height: calc(100vh - 300px);
}
@include mobile {
min-height: 360px;
max-height: 480px;
flex: 0 0 auto;
overflow: auto;
}
}
.errorPanel {
@@ -190,6 +223,17 @@
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
@include mobile {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: $spacing-xs;
> span {
width: 100%;
}
}
}
.loadMoreCount {
@@ -201,6 +245,16 @@
display: flex;
align-items: center;
gap: $spacing-md;
@include mobile {
width: 100%;
flex-wrap: wrap;
gap: $spacing-sm;
> span {
white-space: nowrap;
}
}
}
.logList {
@@ -227,9 +281,18 @@
background: rgba(59, 130, 246, 0.06);
}
@include tablet {
grid-template-columns: 140px 1fr;
gap: $spacing-sm;
padding: 8px 10px;
font-size: 12px;
}
@include mobile {
grid-template-columns: 1fr;
gap: $spacing-xs;
padding: 8px 10px;
font-size: 11.5px;
}
}
@@ -371,12 +434,17 @@
@include mobile {
max-width: 100%;
flex-basis: 100%;
}
}
.message {
color: var(--text-secondary);
word-break: break-word;
@include mobile {
flex-basis: 100%;
}
}
@media (max-height: 820px) {
@@ -402,10 +470,65 @@
}
.logPanel {
height: 360px;
min-height: 200px;
max-height: calc(100vh - 280px);
}
.logRow {
padding: 8px 10px;
font-size: 12px;
}
.errorPanel {
height: 360px;
}
}
@media (max-height: 600px) {
.pageTitle {
font-size: 20px;
margin-bottom: $spacing-sm;
}
.tabBar {
margin-bottom: $spacing-sm;
}
.tabItem {
padding: 8px 12px;
font-size: 13px;
}
.content {
gap: $spacing-sm;
}
.filters {
margin-bottom: $spacing-sm;
gap: $spacing-sm;
}
.logCard {
padding: $spacing-sm;
}
.logPanel {
min-height: 160px;
max-height: calc(100vh - 220px);
}
.logRow {
padding: 6px 8px;
font-size: 11px;
grid-template-columns: 130px 1fr;
gap: $spacing-xs;
}
.loadMoreBanner {
padding: 6px 10px;
}
.errorPanel {
height: 280px;
}
}

View File

@@ -1,9 +1,11 @@
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconDownload,
@@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100;
const LOAD_MORE_LINES = 200;
const MAX_BUFFER_LINES = 10000;
const LOAD_MORE_THRESHOLD_PX = 72;
const LONG_PRESS_MS = 650;
const LONG_PRESS_MOVE_THRESHOLD = 10;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
@@ -370,14 +374,22 @@ export function LogsPage() {
const [autoRefresh, setAutoRefresh] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(false);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const longPressRef = useRef<{
timer: number | null;
startX: number;
startY: number;
fired: boolean;
} | null>(null);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
@@ -647,6 +659,85 @@ export function LogsPage() {
}
};
const clearLongPressTimer = () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
const startLongPress = (event: ReactPointerEvent<HTMLDivElement>, id?: string) => {
if (!requestLogEnabled) return;
if (!id) return;
if (requestLogId) return;
clearLongPressTimer();
longPressRef.current = {
timer: window.setTimeout(() => {
setRequestLogId(id);
if (longPressRef.current) {
longPressRef.current.fired = true;
longPressRef.current.timer = null;
}
}, LONG_PRESS_MS),
startX: event.clientX,
startY: event.clientY,
fired: false,
};
};
const cancelLongPress = () => {
clearLongPressTimer();
longPressRef.current = null;
};
const handleLongPressMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const current = longPressRef.current;
if (!current || current.timer === null || current.fired) return;
const deltaX = Math.abs(event.clientX - current.startX);
const deltaY = Math.abs(event.clientY - current.startY);
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
cancelLongPress();
}
};
const closeRequestLogModal = () => {
if (requestLogDownloading) return;
setRequestLogId(null);
};
const downloadRequestLog = async (id: string) => {
setRequestLogDownloading(true);
try {
const response = await logsApi.downloadRequestLogById(id);
const blob = new Blob([response.data], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `request-${id}.log`;
a.click();
window.URL.revokeObjectURL(url);
showNotification(t('logs.request_log_download_success'), 'success');
setRequestLogId(null);
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setRequestLogDownloading(false);
}
};
useEffect(() => {
return () => {
if (longPressRef.current?.timer) {
window.clearTimeout(longPressRef.current.timer);
longPressRef.current.timer = null;
}
};
}, []);
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('logs.title')}</h1>
@@ -760,6 +851,10 @@ export function LogsPage() {
</div>
</div>
<div className="hint">
{requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')}
</div>
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? (
@@ -795,6 +890,11 @@ export function LogsPage() {
onDoubleClick={() => {
void copyLogLine(line.raw);
}}
onPointerDown={(event) => startLongPress(event, line.requestId)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onPointerMove={handleLongPressMove}
title={t('logs.double_click_copy_hint', {
defaultValue: 'Double-click to copy',
})}
@@ -946,6 +1046,32 @@ export function LogsPage() {
</Card>
)}
</div>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
title={t('logs.request_log_download_title')}
footer={
<>
<Button variant="secondary" onClick={closeRequestLogModal} disabled={requestLogDownloading}>
{t('common.cancel')}
</Button>
<Button
onClick={() => {
if (requestLogId) {
void downloadRequestLog(requestLogId);
}
}}
loading={requestLogDownloading}
disabled={!requestLogId}
>
{t('common.confirm')}
</Button>
</>
}
>
{requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null}
</Modal>
</div>
);
}

View File

@@ -114,3 +114,25 @@
gap: $spacing-sm;
margin-top: $spacing-sm;
}
.filePicker {
display: flex;
align-items: center;
gap: $spacing-sm;
flex-wrap: wrap;
}
.fileName {
flex: 1;
min-width: 220px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
}
.fileNamePlaceholder {
color: var(--text-secondary);
}

View File

@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
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 { vertexApi, type VertexImportResponse } from '@/services/api/vertex';
import styles from './OAuthPage.module.scss';
import iconOpenaiLight from '@/assets/icons/openai-light.svg';
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
@@ -13,6 +14,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg';
import iconGemini from '@/assets/icons/gemini.svg';
import iconQwen from '@/assets/icons/qwen.svg';
import iconIflow from '@/assets/icons/iflow.svg';
import iconVertex from '@/assets/icons/vertex.svg';
interface ProviderState {
url?: string;
@@ -36,6 +38,22 @@ interface IFlowCookieState {
errorType?: 'error' | 'warning';
}
interface VertexImportResult {
projectId?: string;
email?: string;
location?: string;
authFile?: string;
}
interface VertexImportState {
file?: File;
fileName: string;
location: string;
loading: boolean;
error?: string;
result?: VertexImportResult;
}
const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [
{ id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: { light: iconOpenaiLight, dark: iconOpenaiDark } },
{ id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude },
@@ -57,7 +75,13 @@ export function OAuthPage() {
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: '',
loading: false
});
const timers = useRef<Record<string, number>>({});
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
return () => {
@@ -216,6 +240,64 @@ export function OAuthPage() {
}
};
const handleVertexFilePick = () => {
vertexFileInputRef.current?.click();
};
const handleVertexFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showNotification(t('vertex_import.file_required'), 'warning');
event.target.value = '';
return;
}
setVertexState((prev) => ({
...prev,
file,
fileName: file.name,
error: undefined,
result: undefined
}));
event.target.value = '';
};
const handleVertexImport = async () => {
if (!vertexState.file) {
const message = t('vertex_import.file_required');
setVertexState((prev) => ({ ...prev, error: message }));
showNotification(message, 'warning');
return;
}
const location = vertexState.location.trim();
setVertexState((prev) => ({ ...prev, loading: true, error: undefined, result: undefined }));
try {
const res: VertexImportResponse = await vertexApi.importCredential(
vertexState.file,
location || undefined
);
const result: VertexImportResult = {
projectId: res.project_id,
email: res.email,
location: res.location,
authFile: res['auth-file'] ?? res.auth_file
};
setVertexState((prev) => ({ ...prev, loading: false, result }));
showNotification(t('vertex_import.success'), 'success');
} catch (err: any) {
const message = err?.message || '';
setVertexState((prev) => ({
...prev,
loading: false,
error: message || t('notification.upload_failed')
}));
const notification = message
? `${t('notification.upload_failed')}: ${message}`
: t('notification.upload_failed');
showNotification(notification, 'error');
}
};
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
@@ -328,6 +410,94 @@ export function OAuthPage() {
);
})}
{/* Vertex JSON 登录 */}
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('vertex_import.title')}
</span>
}
extra={
<Button onClick={handleVertexImport} loading={vertexState.loading}>
{t('vertex_import.import_button')}
</Button>
}
>
<div className="hint">{t('vertex_import.description')}</div>
<Input
label={t('vertex_import.location_label')}
hint={t('vertex_import.location_hint')}
value={vertexState.location}
onChange={(e) =>
setVertexState((prev) => ({
...prev,
location: e.target.value
}))
}
placeholder={t('vertex_import.location_placeholder')}
/>
<div className="form-group">
<label>{t('vertex_import.file_label')}</label>
<div className={styles.filePicker}>
<Button variant="secondary" size="sm" onClick={handleVertexFilePick}>
{t('vertex_import.choose_file')}
</Button>
<div
className={`${styles.fileName} ${
vertexState.fileName ? '' : styles.fileNamePlaceholder
}`.trim()}
>
{vertexState.fileName || t('vertex_import.file_placeholder')}
</div>
</div>
<div className="hint">{t('vertex_import.file_hint')}</div>
<input
ref={vertexFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleVertexFileChange}
/>
</div>
{vertexState.error && (
<div className="status-badge error" style={{ marginTop: 8 }}>
{vertexState.error}
</div>
)}
{vertexState.result && (
<div className="connection-box" style={{ marginTop: 12 }}>
<div className="label">{t('vertex_import.result_title')}</div>
<div className="key-value-list">
{vertexState.result.projectId && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_project')}</span>
<span className="value">{vertexState.result.projectId}</span>
</div>
)}
{vertexState.result.email && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_email')}</span>
<span className="value">{vertexState.result.email}</span>
</div>
)}
{vertexState.result.location && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_location')}</span>
<span className="value">{vertexState.result.location}</span>
</div>
)}
{vertexState.result.authFile && (
<div className="key-value-item">
<span className="key">{t('vertex_import.result_file')}</span>
<span className="value">{vertexState.result.authFile}</span>
</div>
)}
</div>
</div>
)}
</Card>
{/* iFlow Cookie 登录 */}
<Card
title={

View File

@@ -18,6 +18,13 @@
gap: 10px;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pageTitle {
font-size: 28px;
font-weight: 700;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo, type CSSProperties } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -19,7 +19,7 @@ import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useThemeStore } from '@/stores';
import { useNotificationStore, useThemeStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import {
formatTokensInMillions,
@@ -63,6 +63,7 @@ interface UsagePayload {
export function UsagePage() {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
@@ -71,6 +72,9 @@ export function UsagePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const importInputRef = useRef<HTMLInputElement | null>(null);
// Model price form state
const [selectedModel, setSelectedModel] = useState('');
@@ -107,6 +111,77 @@ export function UsagePage() {
setModelPrices(loadModelPrices());
}, [loadUsage]);
const handleExport = async () => {
setExporting(true);
try {
const data = await usageApi.exportUsage();
const exportedAt =
typeof data?.exported_at === 'string' ? new Date(data.exported_at) : new Date();
const safeTimestamp = Number.isNaN(exportedAt.getTime())
? new Date().toISOString()
: exportedAt.toISOString();
const filename = `usage-export-${safeTimestamp.replace(/[:.]/g, '-')}.json`;
const blob = new Blob([JSON.stringify(data ?? {}, null, 2)], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
showNotification(t('usage_stats.export_success'), 'success');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.download_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setExporting(false);
}
};
const handleImportClick = () => {
importInputRef.current?.click();
};
const handleImportChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
showNotification(t('usage_stats.import_invalid'), 'error');
return;
}
const result = await usageApi.importUsage(payload);
showNotification(
t('usage_stats.import_success', {
added: result?.added ?? 0,
skipped: result?.skipped ?? 0,
total: result?.total_requests ?? 0,
failed: result?.failed_requests ?? 0
}),
'success'
);
await loadUsage();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
`${t('notification.upload_failed')}${message ? `: ${message}` : ''}`,
'error'
);
} finally {
setImporting(false);
}
};
// Calculate derived data
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage
@@ -527,14 +602,41 @@ export function UsagePage() {
)}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={handleExport}
loading={exporting}
disabled={loading || importing}
>
{t('usage_stats.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
loading={importing}
disabled={loading || exporting}
>
{t('usage_stats.import')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={loadUsage}
disabled={loading || exporting || importing}
>
{loading ? t('common.loading') : t('usage_stats.refresh')}
</Button>
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportChange}
/>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}

View File

@@ -11,3 +11,4 @@ export * from './logs';
export * from './version';
export * from './models';
export * from './transformers';
export * from './vertex';

View File

@@ -39,4 +39,10 @@ export const logsApi = {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
downloadRequestLogById: (id: string) =>
apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, {
responseType: 'blob',
timeout: LOGS_TIMEOUT_MS
}),
};

View File

@@ -7,12 +7,38 @@ import { computeKeyStats, KeyStats } from '@/utils/usage';
const USAGE_TIMEOUT_MS = 60 * 1000;
export interface UsageExportPayload {
version?: number;
exported_at?: string;
usage?: Record<string, unknown>;
[key: string]: unknown;
}
export interface UsageImportResponse {
added?: number;
skipped?: number;
total_requests?: number;
failed_requests?: number;
[key: string]: unknown;
}
export const usageApi = {
/**
* 获取使用统计原始数据
*/
getUsage: () => apiClient.get('/usage', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导出使用统计快照
*/
exportUsage: () => apiClient.get<UsageExportPayload>('/usage/export', { timeout: USAGE_TIMEOUT_MS }),
/**
* 导入使用统计快照
*/
importUsage: (payload: unknown) =>
apiClient.post<UsageImportResponse>('/usage/import', payload, { timeout: USAGE_TIMEOUT_MS }),
/**
* 计算密钥成功/失败统计,必要时会先获取 usage 数据
*/

View File

@@ -0,0 +1,25 @@
/**
* Vertex credential import API
*/
import { apiClient } from './client';
export interface VertexImportResponse {
status: 'ok';
project_id?: string;
email?: string;
location?: string;
'auth-file'?: string;
auth_file?: string;
}
export const vertexApi = {
importCredential: (file: File, location?: string) => {
const formData = new FormData();
formData.append('file', file);
if (location) {
formData.append('location', location);
}
return apiClient.postForm<VertexImportResponse>('/vertex/import', formData);
}
};

View File

@@ -12,6 +12,13 @@
overflow: hidden;
background: var(--bg-secondary);
color: var(--text-primary);
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: 100vh;
overflow: visible;
overflow-y: auto;
}
}
.main-header {
@@ -230,6 +237,16 @@
@supports (height: 100dvh) {
height: calc(100dvh - var(--header-height));
}
@media (max-width: $breakpoint-mobile) {
height: auto;
min-height: calc(100vh - var(--header-height));
overflow: visible;
@supports (min-height: 100dvh) {
min-height: calc(100dvh - var(--header-height));
}
}
}
.sidebar {
@@ -328,16 +345,38 @@
min-width: 0;
overflow-y: auto;
height: 100%;
&.content-logs {
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
overflow: visible;
overflow-y: auto;
height: auto;
}
}
}
.main-content {
flex: 0 0 auto;
flex: 1 0 auto;
padding: $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-lg;
overflow-x: hidden;
&.main-content-logs {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
@media (max-width: $breakpoint-mobile) {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
}
}
@media (max-width: $breakpoint-mobile) {
padding: $spacing-md;
}

View File

@@ -754,6 +754,103 @@ export function buildChartData(
/**
* 依据 usage 数据计算密钥使用统计
*/
/**
* 状态栏单个格子的状态
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
* 状态栏数据
*/
export interface StatusBarData {
blocks: StatusBlockState[];
successRate: number;
totalSuccess: number;
totalFailure: number;
}
/**
* 计算状态栏数据最近1小时分为20个5分钟的时间块
* 注意20个块 × 5分钟 = 100分钟但我们只使用最近60分钟的数据
* 所以实际只有最后12个块可能有数据前8个块将始终为 idle
*/
export function calculateStatusBarData(
usageDetails: UsageDetail[],
sourceFilter?: string,
authIndexFilter?: number
): StatusBarData {
const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
const HOUR_MS = 60 * 60 * 1000;
const now = Date.now();
const hourAgo = now - HOUR_MS;
// Initialize blocks
const blockStats: Array<{ success: number; failure: number }> = Array.from(
{ length: BLOCK_COUNT },
() => ({ success: 0, failure: 0 })
);
let totalSuccess = 0;
let totalFailure = 0;
// Filter and bucket the usage details
usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
return;
}
// Apply filters if provided
if (sourceFilter !== undefined && detail.source !== sourceFilter) {
return;
}
if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) {
return;
}
// Calculate which block this falls into (0 = oldest, 19 = newest)
const ageMs = now - timestamp;
const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS);
if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) {
if (detail.failed) {
blockStats[blockIndex].failure += 1;
totalFailure += 1;
} else {
blockStats[blockIndex].success += 1;
totalSuccess += 1;
}
}
});
// Convert stats to block states
const blocks: StatusBlockState[] = blockStats.map((stat) => {
if (stat.success === 0 && stat.failure === 0) {
return 'idle';
}
if (stat.failure === 0) {
return 'success';
}
if (stat.success === 0) {
return 'failure';
}
return 'mixed';
});
// Calculate success rate
const total = totalSuccess + totalFailure;
const successRate = total > 0 ? (totalSuccess / total) * 100 : 100;
return {
blocks,
successRate,
totalSuccess,
totalFailure
};
}
export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats {
if (!usageData) {
return { bySource: {}, byAuthIndex: {} };