feat: 增强文档和监控功能

主要更新:
- 完善 README 文档,新增中文详细使用说明与监控中心介绍
- 优化 README.md 文档内容和格式,增加英文和中文文档切换链接
- 新增监控中心模块,支持请求日志、统计分析和模型管理
- 增强 AI 提供商配置页面,添加配置搜索功能
- 更新 .gitignore,移除无效注释和调整条目名称
- 删除 README_CN.md 文件,统一文档结构

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
kongkongyo
2026-01-19 00:58:48 +08:00
parent d077b5dd26
commit e4850656a5
37 changed files with 6435 additions and 333 deletions

View File

@@ -20,7 +20,53 @@
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
margin: 0;
}
.pageHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.searchBox {
flex: 0 1 320px;
min-width: 200px;
@include mobile {
flex: 1 1 100%;
}
:global(.form-group) {
margin-bottom: 0;
}
}
.searchEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl * 2;
text-align: center;
background: var(--bg-secondary);
border: 1px dashed var(--border-primary);
border-radius: 12px;
}
.searchEmptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: $spacing-sm;
}
.searchEmptyDesc {
font-size: 14px;
color: var(--text-tertiary);
}
.content {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList';
import {
@@ -20,6 +20,7 @@ import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { Input } from '@/components/ui/Input';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
@@ -50,10 +51,132 @@ export function AiProvidersPage() {
const [configSwitchingKey, setConfigSwitchingKey] = useState<string | null>(null);
const [modal, setModal] = useState<ProviderModal | null>(null);
const [ampcodeBusy, setAmpcodeBusy] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const normalizedQuery = searchQuery.trim().toLowerCase();
const filteredGeminiKeys = useMemo(() => {
if (!normalizedQuery) return geminiKeys.map((item, index) => ({ item, originalIndex: index }));
return geminiKeys
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
...(item.excludedModels || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [geminiKeys, normalizedQuery]);
const filteredCodexConfigs = useMemo(() => {
if (!normalizedQuery) return codexConfigs.map((item, index) => ({ item, originalIndex: index }));
return codexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [codexConfigs, normalizedQuery]);
const filteredClaudeConfigs = useMemo(() => {
if (!normalizedQuery) return claudeConfigs.map((item, index) => ({ item, originalIndex: index }));
return claudeConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.excludedModels || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [claudeConfigs, normalizedQuery]);
const filteredVertexConfigs = useMemo(() => {
if (!normalizedQuery) return vertexConfigs.map((item, index) => ({ item, originalIndex: index }));
return vertexConfigs
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.apiKey,
item.prefix,
item.baseUrl,
item.proxyUrl,
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [vertexConfigs, normalizedQuery]);
const filteredOpenaiProviders = useMemo(() => {
if (!normalizedQuery)
return openaiProviders.map((item, index) => ({ item, originalIndex: index }));
return openaiProviders
.map((item, index) => ({ item, originalIndex: index }))
.filter(({ item }) => {
const searchFields = [
item.name,
item.prefix,
item.baseUrl,
item.testModel,
...(item.apiKeyEntries?.map((e) => e.apiKey) || []),
...(item.apiKeyEntries?.map((e) => e.proxyUrl) || []),
...(item.models?.map((m) => m.name) || []),
...(item.models?.map((m) => m.alias) || []),
...Object.keys(item.headers || {}),
...Object.values(item.headers || {}),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
});
}, [openaiProviders, normalizedQuery]);
const showAmpcode = useMemo(() => {
if (!normalizedQuery) return true;
const ampcode = config?.ampcode;
if (!ampcode) return false;
const searchFields = [
ampcode.upstreamUrl,
ampcode.upstreamApiKey,
...(ampcode.modelMappings?.map((m) => m.from) || []),
...(ampcode.modelMappings?.map((m) => m.to) || []),
];
return searchFields.some((field) => field?.toLowerCase().includes(normalizedQuery));
}, [config?.ampcode, normalizedQuery]);
const hasSearchResults =
filteredGeminiKeys.length > 0 ||
filteredCodexConfigs.length > 0 ||
filteredClaudeConfigs.length > 0 ||
filteredVertexConfigs.length > 0 ||
filteredOpenaiProviders.length > 0 ||
showAmpcode;
const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
const getErrorMessage = (err: unknown) => {
@@ -507,112 +630,171 @@ export function AiProvidersPage() {
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.searchBox}>
<Input
type="text"
placeholder={t('ai_providers.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={geminiModalIndex}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
onCloseModal={closeModal}
onSave={saveGemini}
/>
{normalizedQuery && !hasSearchResults && (
<div className={styles.searchEmpty}>
<div className={styles.searchEmptyTitle}>{t('ai_providers.search_empty_title')}</div>
<div className={styles.searchEmptyDesc}>{t('ai_providers.search_empty_desc')}</div>
</div>
)}
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={codexModalIndex}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
{filteredGeminiKeys.length > 0 && (
<GeminiSection
configs={filteredGeminiKeys.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={
geminiModalIndex !== null
? filteredGeminiKeys.findIndex(({ originalIndex }) => originalIndex === geminiModalIndex)
: null
}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(filteredGeminiKeys[index]?.originalIndex ?? index)}
onDelete={(index) => deleteGemini(filteredGeminiKeys[index]?.originalIndex ?? index)}
onToggle={(index, enabled) =>
void setConfigEnabled('gemini', filteredGeminiKeys[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={saveGemini}
/>
)}
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={claudeModalIndex}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
{filteredCodexConfigs.length > 0 && (
<CodexSection
configs={filteredCodexConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={
codexModalIndex !== null
? filteredCodexConfigs.findIndex(({ originalIndex }) => originalIndex === codexModalIndex)
: null
}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', filteredCodexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) =>
void deleteProviderEntry('codex', filteredCodexConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('codex', filteredCodexConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
)}
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)}
onDelete={deleteVertex}
onCloseModal={closeModal}
onSave={saveVertex}
/>
{filteredClaudeConfigs.length > 0 && (
<ClaudeSection
configs={filteredClaudeConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={
claudeModalIndex !== null
? filteredClaudeConfigs.findIndex(({ originalIndex }) => originalIndex === claudeModalIndex)
: null
}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)}
onDelete={(index) =>
void deleteProviderEntry('claude', filteredClaudeConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('claude', filteredClaudeConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
)}
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
{filteredVertexConfigs.length > 0 && (
<VertexSection
configs={filteredVertexConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={
vertexModalIndex !== null
? filteredVertexConfigs.findIndex(({ originalIndex }) => originalIndex === vertexModalIndex)
: null
}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(filteredVertexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) => deleteVertex(filteredVertexConfigs[index]?.originalIndex ?? index)}
onCloseModal={closeModal}
onSave={saveVertex}
/>
)}
<OpenAISection
configs={openaiProviders}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={openaiModalIndex}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(index)}
onDelete={deleteOpenai}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
{showAmpcode && (
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
)}
{filteredOpenaiProviders.length > 0 && (
<OpenAISection
configs={filteredOpenaiProviders.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={
openaiModalIndex !== null
? filteredOpenaiProviders.findIndex(({ originalIndex }) => originalIndex === openaiModalIndex)
: null
}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onDelete={(index) => deleteOpenai(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
)}
</div>
</div>
);

File diff suppressed because it is too large Load Diff

379
src/pages/MonitorPage.tsx Normal file
View File

@@ -0,0 +1,379 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
BarController,
LineController,
ArcElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import { usageApi, providersApi } from '@/services/api';
import { KpiCards } from '@/components/monitor/KpiCards';
import { ModelDistributionChart } from '@/components/monitor/ModelDistributionChart';
import { DailyTrendChart } from '@/components/monitor/DailyTrendChart';
import { HourlyModelChart } from '@/components/monitor/HourlyModelChart';
import { HourlyTokenChart } from '@/components/monitor/HourlyTokenChart';
import { ChannelStats } from '@/components/monitor/ChannelStats';
import { FailureAnalysis } from '@/components/monitor/FailureAnalysis';
import { RequestLogs } from '@/components/monitor/RequestLogs';
import styles from './MonitorPage.module.scss';
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
BarController,
LineController,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
// 时间范围选项
type TimeRange = 1 | 7 | 14 | 30;
export interface UsageDetail {
timestamp: string;
failed: boolean;
source: string;
auth_index: string;
tokens: {
input_tokens: number;
output_tokens: number;
reasoning_tokens: number;
cached_tokens: number;
total_tokens: number;
};
}
export interface UsageData {
apis: Record<string, {
models: Record<string, {
details: UsageDetail[];
}>;
}>;
}
export function MonitorPage() {
const { t } = useTranslation();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
// 状态
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [usageData, setUsageData] = useState<UsageData | null>(null);
const [timeRange, setTimeRange] = useState<TimeRange>(7);
const [apiFilter, setApiFilter] = useState('');
const [providerMap, setProviderMap] = useState<Record<string, string>>({});
const [providerModels, setProviderModels] = useState<Record<string, Set<string>>>({});
const [providerTypeMap, setProviderTypeMap] = useState<Record<string, string>>({});
// 加载渠道名称映射(支持所有提供商类型)
const loadProviderMap = useCallback(async () => {
try {
const map: Record<string, string> = {};
const modelsMap: Record<string, Set<string>> = {};
const typeMap: Record<string, string> = {};
// 并行加载所有提供商配置
const [openaiProviders, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs] = await Promise.all([
providersApi.getOpenAIProviders().catch(() => []),
providersApi.getGeminiKeys().catch(() => []),
providersApi.getClaudeConfigs().catch(() => []),
providersApi.getCodexConfigs().catch(() => []),
providersApi.getVertexConfigs().catch(() => []),
]);
// 处理 OpenAI 兼容提供商
openaiProviders.forEach((provider) => {
const providerName = provider.headers?.['X-Provider'] || provider.name || 'unknown';
const modelSet = new Set<string>();
(provider.models || []).forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
const apiKeyEntries = provider.apiKeyEntries || [];
apiKeyEntries.forEach((entry) => {
const apiKey = entry.apiKey;
if (apiKey) {
map[apiKey] = providerName;
modelsMap[apiKey] = modelSet;
typeMap[apiKey] = 'OpenAI';
}
});
if (provider.name) {
map[provider.name] = providerName;
modelsMap[provider.name] = modelSet;
typeMap[provider.name] = 'OpenAI';
}
});
// 处理 Gemini 提供商
geminiKeys.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Gemini';
map[apiKey] = providerName;
typeMap[apiKey] = 'Gemini';
}
});
// 处理 Claude 提供商
claudeConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Claude';
map[apiKey] = providerName;
typeMap[apiKey] = 'Claude';
// 存储模型集合
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
// 处理 Codex 提供商
codexConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Codex';
map[apiKey] = providerName;
typeMap[apiKey] = 'Codex';
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
// 处理 Vertex 提供商
vertexConfigs.forEach((config) => {
const apiKey = config.apiKey;
if (apiKey) {
const providerName = config.prefix?.trim() || 'Vertex';
map[apiKey] = providerName;
typeMap[apiKey] = 'Vertex';
if (config.models && config.models.length > 0) {
const modelSet = new Set<string>();
config.models.forEach((m) => {
if (m.alias) modelSet.add(m.alias);
if (m.name) modelSet.add(m.name);
});
modelsMap[apiKey] = modelSet;
}
}
});
setProviderMap(map);
setProviderModels(modelsMap);
setProviderTypeMap(typeMap);
} catch (err) {
console.warn('Monitor: Failed to load provider map:', err);
}
}, []);
// 加载数据
const loadData = useCallback(async () => {
setLoading(true);
setError(null);
try {
// 并行加载使用数据和渠道映射
const [response] = await Promise.all([
usageApi.getUsage(),
loadProviderMap()
]);
// API 返回的数据可能在 response.usage 或直接在 response 中
const data = response?.usage ?? response;
setUsageData(data as UsageData);
} catch (err) {
const message = err instanceof Error ? err.message : t('common.unknown_error');
console.error('Monitor: Error loading data:', err);
setError(message);
} finally {
setLoading(false);
}
}, [t, loadProviderMap]);
// 初始加载
useEffect(() => {
loadData();
}, [loadData]);
// 响应头部刷新
useHeaderRefresh(loadData);
// 根据时间范围过滤数据
const filteredData = useMemo(() => {
if (!usageData?.apis) {
return null;
}
const now = new Date();
const cutoffTime = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
const filtered: UsageData = { apis: {} };
Object.entries(usageData.apis).forEach(([apiKey, apiData]) => {
// 如果有 API 过滤器,检查是否匹配
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
return;
}
// 检查 apiData 是否有 models 属性
if (!apiData?.models) {
return;
}
const filteredModels: Record<string, { details: UsageDetail[] }> = {};
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
// 检查 modelData 是否有 details 属性
if (!modelData?.details || !Array.isArray(modelData.details)) {
return;
}
const filteredDetails = modelData.details.filter((detail) => {
const timestamp = new Date(detail.timestamp);
return timestamp >= cutoffTime;
});
if (filteredDetails.length > 0) {
filteredModels[modelName] = { details: filteredDetails };
}
});
if (Object.keys(filteredModels).length > 0) {
filtered.apis[apiKey] = { models: filteredModels };
}
});
return filtered;
}, [usageData, timeRange, apiFilter]);
// 处理时间范围变化
const handleTimeRangeChange = (range: TimeRange) => {
setTimeRange(range);
};
// 处理 API 过滤应用(触发数据刷新)
const handleApiFilterApply = () => {
loadData();
};
return (
<div className={styles.container}>
{loading && !usageData && (
<div className={styles.loadingOverlay} aria-busy="true">
<div className={styles.loadingOverlayContent}>
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
</div>
</div>
)}
{/* 页面标题 */}
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('monitor.title')}</h1>
<div className={styles.headerActions}>
<Button
variant="secondary"
size="sm"
onClick={loadData}
disabled={loading}
>
{loading ? t('common.loading') : t('common.refresh')}
</Button>
</div>
</div>
{/* 错误提示 */}
{error && <div className={styles.errorBox}>{error}</div>}
{/* 时间范围和 API 过滤 */}
<div className={styles.filters}>
<div className={styles.filterGroup}>
<span className={styles.filterLabel}>{t('monitor.time_range')}</span>
<div className={styles.timeButtons}>
{([1, 7, 14, 30] as TimeRange[]).map((range) => (
<button
key={range}
className={`${styles.timeButton} ${timeRange === range ? styles.active : ''}`}
onClick={() => handleTimeRangeChange(range)}
>
{range === 1 ? t('monitor.today') : t('monitor.last_n_days', { n: range })}
</button>
))}
</div>
</div>
<div className={styles.filterGroup}>
<span className={styles.filterLabel}>{t('monitor.api_filter')}</span>
<input
type="text"
className={styles.filterInput}
placeholder={t('monitor.api_filter_placeholder')}
value={apiFilter}
onChange={(e) => setApiFilter(e.target.value)}
/>
<Button variant="secondary" size="sm" onClick={handleApiFilterApply}>
{t('monitor.apply')}
</Button>
</div>
</div>
{/* KPI 卡片 */}
<KpiCards data={filteredData} loading={loading} timeRange={timeRange} />
{/* 图表区域 */}
<div className={styles.chartsGrid}>
<ModelDistributionChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
<DailyTrendChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
</div>
{/* 小时级图表 */}
<HourlyModelChart data={filteredData} loading={loading} isDark={isDark} />
<HourlyTokenChart data={filteredData} loading={loading} isDark={isDark} />
{/* 统计表格 */}
<div className={styles.statsGrid}>
<ChannelStats data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
<FailureAnalysis data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
</div>
{/* 请求日志 */}
<RequestLogs
data={filteredData}
loading={loading}
providerMap={providerMap}
providerTypeMap={providerTypeMap}
apiFilter={apiFilter}
/>
</div>
);
}

View File

@@ -181,7 +181,7 @@ export function SystemPage() {
</a>
<a
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
href="https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
target="_blank"
rel="noopener noreferrer"
className={styles.linkCard}