mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03bf58671e | ||
|
|
cb6b810d6d | ||
|
|
408e6e5872 | ||
|
|
b3808add0f | ||
|
|
0b2e6efe28 | ||
|
|
8ca6d31a26 |
1
src/assets/icons/vertex.svg
Normal file
1
src/assets/icons/vertex.svg
Normal 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 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './logs';
|
||||
export * from './version';
|
||||
export * from './models';
|
||||
export * from './transformers';
|
||||
export * from './vertex';
|
||||
|
||||
25
src/services/api/vertex.ts
Normal file
25
src/services/api/vertex.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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,6 +345,16 @@
|
||||
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 {
|
||||
@@ -338,6 +365,18 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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: {} };
|
||||
|
||||
Reference in New Issue
Block a user