Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2539710075 | ||
|
|
6bdc87aed6 | ||
|
|
268b92c59b | ||
|
|
c89bbd5098 | ||
|
|
2715f44a5e | ||
|
|
305ddef900 | ||
|
|
7e56d33bf0 | ||
|
|
80daf03fa6 | ||
|
|
883059b031 | ||
|
|
d077b5dd26 | ||
|
|
d79ccc480d | ||
|
|
7b0d6dc7e9 | ||
|
|
b8d7b8997c | ||
|
|
0bb34ca74b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,3 @@ settings.local.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
tmpclaude*
|
||||
.claude
|
||||
CLIProxyAPI
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -9,7 +9,6 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
@@ -1868,33 +1867,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"axios": "^1.13.2",
|
||||
"chart.js": "^4.5.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router-dom';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { NotificationContainer } from '@/components/common/NotificationContainer';
|
||||
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
|
||||
import { SplashScreen } from '@/components/common/SplashScreen';
|
||||
import { MainLayout } from '@/components/layout/MainLayout';
|
||||
import { ProtectedRoute } from '@/router/ProtectedRoute';
|
||||
@@ -61,6 +62,7 @@ function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<NotificationContainer />
|
||||
<ConfirmationModal />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
|
||||
61
src/components/common/ConfirmationModal.tsx
Normal file
61
src/components/common/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
|
||||
export function ConfirmationModal() {
|
||||
const { t } = useTranslation();
|
||||
const confirmation = useNotificationStore((state) => state.confirmation);
|
||||
const hideConfirmation = useNotificationStore((state) => state.hideConfirmation);
|
||||
const setConfirmationLoading = useNotificationStore((state) => state.setConfirmationLoading);
|
||||
|
||||
const { isOpen, isLoading, options } = confirmation;
|
||||
|
||||
if (!isOpen || !options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, message, onConfirm, onCancel, confirmText, cancelText, variant = 'primary' } = options;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setConfirmationLoading(true);
|
||||
await onConfirm();
|
||||
hideConfirmation();
|
||||
} catch (error) {
|
||||
console.error('Confirmation action failed:', error);
|
||||
// Optional: show error notification here if needed,
|
||||
// but usually the calling component handles specific errors.
|
||||
} finally {
|
||||
setConfirmationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
hideConfirmation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleCancel} title={title} closeDisabled={isLoading}>
|
||||
<p style={{ margin: '1rem 0' }}>{message}</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '2rem' }}>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
|
||||
{cancelText || t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
{confirmText || t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@
|
||||
gap: $spacing-lg;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
|
||||
// During animation, exit layer uses absolute positioning
|
||||
&--exit {
|
||||
@@ -22,17 +24,15 @@
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
}
|
||||
|
||||
&--animating &__layer {
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
// When both layers exist, current layer also needs positioning
|
||||
&--animating &__layer:not(&__layer--exit) {
|
||||
&--animating &__layer:not(.page-transition__layer--exit) {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ interface PageTransitionProps {
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const TRANSITION_DURATION = 0.5;
|
||||
const EXIT_DURATION = 0.45;
|
||||
const ENTER_DELAY = 0.08;
|
||||
const TRANSITION_DURATION = 0.35;
|
||||
const TRAVEL_DISTANCE = 60;
|
||||
|
||||
type LayerStatus = 'current' | 'exiting';
|
||||
|
||||
@@ -23,18 +22,14 @@ type Layer = {
|
||||
|
||||
type TransitionDirection = 'forward' | 'backward';
|
||||
|
||||
export function PageTransition({
|
||||
render,
|
||||
getRouteOrder,
|
||||
scrollContainerRef,
|
||||
}: PageTransitionProps) {
|
||||
export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
|
||||
const location = useLocation();
|
||||
const currentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const exitingLayerRef = useRef<HTMLDivElement>(null);
|
||||
const transitionDirectionRef = useRef<TransitionDirection>('forward');
|
||||
const exitScrollOffsetRef = useRef(0);
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
|
||||
const [layers, setLayers] = useState<Layer[]>(() => [
|
||||
{
|
||||
key: location.key,
|
||||
@@ -71,11 +66,11 @@ export function PageTransition({
|
||||
? 'forward'
|
||||
: 'backward';
|
||||
|
||||
let cancelled = false;
|
||||
transitionDirectionRef.current = nextDirection;
|
||||
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setTransitionDirection(nextDirection);
|
||||
setLayers((prev) => {
|
||||
const prevCurrent = prev[prev.length - 1];
|
||||
return [
|
||||
@@ -106,17 +101,18 @@ export function PageTransition({
|
||||
|
||||
if (!currentLayerRef.current) return;
|
||||
|
||||
const currentLayerEl = currentLayerRef.current;
|
||||
const exitingLayerEl = exitingLayerRef.current;
|
||||
|
||||
const scrollContainer = resolveScrollContainer();
|
||||
const scrollOffset = exitScrollOffsetRef.current;
|
||||
if (scrollContainer && scrollOffset > 0) {
|
||||
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
const containerHeight = scrollContainer?.clientHeight ?? 0;
|
||||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight;
|
||||
const travelDistance = Math.max(containerHeight, viewportHeight, 1);
|
||||
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
|
||||
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
|
||||
const transitionDirection = transitionDirectionRef.current;
|
||||
const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
|
||||
const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
|
||||
const exitBaseY = scrollOffset ? -scrollOffset : 0;
|
||||
|
||||
const tl = gsap.timeline({
|
||||
@@ -126,43 +122,46 @@ export function PageTransition({
|
||||
},
|
||||
});
|
||||
|
||||
// Exit animation: fly out to top (slow-to-fast)
|
||||
if (exitingLayerRef.current) {
|
||||
gsap.set(exitingLayerRef.current, { y: exitBaseY });
|
||||
tl.fromTo(
|
||||
exitingLayerRef.current,
|
||||
{ y: exitBaseY, opacity: 1 },
|
||||
// Exit animation: fade out with slight movement (runs simultaneously)
|
||||
if (exitingLayerEl) {
|
||||
gsap.set(exitingLayerEl, { y: exitBaseY });
|
||||
tl.to(
|
||||
exitingLayerEl,
|
||||
{
|
||||
y: exitBaseY + exitToY,
|
||||
opacity: 0,
|
||||
duration: EXIT_DURATION,
|
||||
ease: 'power2.in', // fast finish to clear screen
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
},
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Enter animation: slide in from bottom (slow-to-fast)
|
||||
// Enter animation: fade in with slight movement (runs simultaneously)
|
||||
tl.fromTo(
|
||||
currentLayerRef.current,
|
||||
currentLayerEl,
|
||||
{ y: enterFromY, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: TRANSITION_DURATION,
|
||||
ease: 'power2.out', // smooth settle
|
||||
clearProps: 'transform,opacity',
|
||||
ease: 'circ.out',
|
||||
force3D: true,
|
||||
onComplete: () => {
|
||||
if (currentLayerEl) {
|
||||
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
|
||||
}
|
||||
},
|
||||
},
|
||||
ENTER_DELAY
|
||||
0
|
||||
);
|
||||
|
||||
return () => {
|
||||
tl.kill();
|
||||
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]);
|
||||
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
|
||||
};
|
||||
}, [isAnimating, transitionDirection, resolveScrollContainer]);
|
||||
}, [isAnimating, resolveScrollContainer]);
|
||||
|
||||
return (
|
||||
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
IconShield,
|
||||
IconSlidersHorizontal,
|
||||
IconTimer,
|
||||
IconActivity,
|
||||
} from '@/components/ui/icons';
|
||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||
import {
|
||||
@@ -51,7 +50,6 @@ const sidebarIcons: Record<string, ReactNode> = {
|
||||
config: <IconSettings size={18} />,
|
||||
logs: <IconScrollText size={18} />,
|
||||
system: <IconInfo size={18} />,
|
||||
monitor: <IconActivity size={18} />,
|
||||
};
|
||||
|
||||
// Header action icons - smaller size for header buttons
|
||||
@@ -371,7 +369,6 @@ export function MainLayout() {
|
||||
? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||
: []),
|
||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
||||
{ path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor },
|
||||
];
|
||||
const navOrder = navItems.map((item) => item.path);
|
||||
const getRouteOrder = (pathname: string) => {
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ChannelStatsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelStat {
|
||||
requests: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface ChannelStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
successRate: number;
|
||||
lastRequestTime: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
models: Record<string, ModelStat>;
|
||||
}
|
||||
|
||||
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算渠道统计数据
|
||||
const channelStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
const stats: Record<string, ChannelStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 获取渠道显示信息
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
// 只统计在 providerMap 中存在的渠道
|
||||
if (!provider) return;
|
||||
|
||||
const displayName = `${provider} (${masked})`;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
lastRequestTime: 0,
|
||||
recentRequests: [],
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
stats[displayName].totalRequests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedRequests++;
|
||||
} else {
|
||||
stats[displayName].successRequests++;
|
||||
}
|
||||
|
||||
// 更新最近请求时间
|
||||
if (timestamp > stats[displayName].lastRequestTime) {
|
||||
stats[displayName].lastRequestTime = timestamp;
|
||||
}
|
||||
|
||||
// 收集请求状态
|
||||
stats[displayName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
|
||||
// 模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
requests: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].requests++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failed++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.successRate = stat.totalRequests > 0
|
||||
? (stat.successRequests / stat.totalRequests) * 100
|
||||
: 0;
|
||||
// 按时间排序,取最近12个
|
||||
stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
stat.recentRequests = stat.recentRequests.slice(-12);
|
||||
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.requests > 0
|
||||
? (model.success / model.requests) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.totalRequests > 0)
|
||||
.sort((a, b) => b.totalRequests - a.totalRequests)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
channelStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [channelStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return channelStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
if (filterStatus === 'success' && stat.failedRequests > 0) return false;
|
||||
if (filterStatus === 'failed' && stat.failedRequests === 0) return false;
|
||||
return true;
|
||||
});
|
||||
}, [channelStats, filterChannel, filterModel, filterStatus]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.channel.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_status')}</option>
|
||||
<option value="success">{t('monitor.channel.only_success')}</option>
|
||||
<option value="failed">{t('monitor.channel.only_failed')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.header_name')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>{stat.totalRequests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(stat.successRate, styles)}>
|
||||
{stat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(stat.lastRequestTime)}</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={5} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按请求数降序
|
||||
return b[1].requests - a[1].requests;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.requests.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failed}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface DailyTrendChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
interface DailyStat {
|
||||
date: string;
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}
|
||||
|
||||
export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 按日期聚合数据
|
||||
const dailyData = useMemo((): DailyStat[] => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const dailyStats: Record<string, {
|
||||
requests: number;
|
||||
successRequests: number;
|
||||
failedRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cachedTokens: number;
|
||||
}> = {};
|
||||
|
||||
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
|
||||
const getLocalDateString = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 使用本地日期而非 UTC 日期
|
||||
const date = getLocalDateString(detail.timestamp);
|
||||
if (!dailyStats[date]) {
|
||||
dailyStats[date] = {
|
||||
requests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
};
|
||||
}
|
||||
dailyStats[date].requests++;
|
||||
if (detail.failed) {
|
||||
dailyStats[date].failedRequests++;
|
||||
} else {
|
||||
dailyStats[date].successRequests++;
|
||||
// 只统计成功请求的 Token
|
||||
dailyStats[date].inputTokens += detail.tokens.input_tokens || 0;
|
||||
dailyStats[date].outputTokens += detail.tokens.output_tokens || 0;
|
||||
dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并按日期排序
|
||||
return Object.entries(dailyStats)
|
||||
.map(([date, stats]) => ({ date, ...stats }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [data]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = dailyData.map((item) => {
|
||||
const date = new Date(item.date);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.trend.requests'),
|
||||
data: dailyData.map((item) => item.requests),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#3b82f6',
|
||||
borderWidth: 3,
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
yAxisID: 'y1',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#3b82f6',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.input_tokens'),
|
||||
data: dailyData.map((item) => item.inputTokens / 1000),
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderColor: 'rgba(34, 197, 94, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 0,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.trend.output_tokens'),
|
||||
data: dailyData.map((item) => item.outputTokens / 1000),
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderColor: 'rgba(249, 115, 22, 0.7)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
stack: 'tokens',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [dailyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 16,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
if (context.dataset.yAxisID === 'y1') {
|
||||
return `${label}: ${value.toLocaleString()}`;
|
||||
}
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
position: 'left' as const,
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.trend.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {t('monitor.trend.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || dailyData.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* 禁用模型确认弹窗组件
|
||||
* 封装三次确认的 UI 逻辑
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { DisableState } from '@/utils/monitor';
|
||||
|
||||
interface DisableModelModalProps {
|
||||
/** 禁用状态 */
|
||||
disableState: DisableState | null;
|
||||
/** 是否正在禁用中 */
|
||||
disabling: boolean;
|
||||
/** 确认回调 */
|
||||
onConfirm: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DisableModelModal({
|
||||
disableState,
|
||||
disabling,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DisableModelModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh';
|
||||
|
||||
// 获取警告内容
|
||||
const getWarningContent = () => {
|
||||
if (!disableState) return null;
|
||||
|
||||
if (disableState.step === 1) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
|
||||
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
|
||||
<strong>{disableState.displayName}</strong>
|
||||
{isZh ? ' 吗?' : '?'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (disableState.step === 2) {
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
|
||||
{isZh
|
||||
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
|
||||
: '⚠️ Warning: this removes the model mapping from config!'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
|
||||
{isZh
|
||||
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
|
||||
: "🚨 Final confirmation: you'll need to add it back manually later!"}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
// 获取确认按钮文本
|
||||
const getConfirmButtonText = () => {
|
||||
if (!disableState) return '';
|
||||
const btnTexts = isZh
|
||||
? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)']
|
||||
: ['Confirm (3)', "I'm sure (2)", 'Disable now (1)'];
|
||||
return btnTexts[disableState.step - 1] || btnTexts[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!disableState}
|
||||
onClose={onCancel}
|
||||
title={t('monitor.logs.disable_confirm_title')}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{getWarningContent()}
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={disabling}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
disabled={disabling}
|
||||
>
|
||||
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
import { useMemo, useState, useCallback, Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import {
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
filterDataByTimeRange,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface FailureAnalysisProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerModels: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface ModelFailureStat {
|
||||
success: number;
|
||||
failure: number;
|
||||
total: number;
|
||||
successRate: number;
|
||||
recentRequests: { failed: boolean; timestamp: number }[];
|
||||
lastTimestamp: number;
|
||||
}
|
||||
|
||||
interface FailureStat {
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
maskedKey: string;
|
||||
failedCount: number;
|
||||
lastFailTime: number;
|
||||
models: Record<string, ModelFailureStat>;
|
||||
}
|
||||
|
||||
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||
const [filterChannel, setFilterChannel] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick: onDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
} = useDisableModel({ providerMap, providerModels });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据时间范围过滤数据
|
||||
const timeFilteredData = useMemo(() => {
|
||||
return filterDataByTimeRange(data, timeRange, customRange);
|
||||
}, [data, timeRange, customRange]);
|
||||
|
||||
// 计算失败统计数据
|
||||
const failureStats = useMemo(() => {
|
||||
if (!timeFilteredData?.apis) return [];
|
||||
|
||||
// 首先收集有失败记录的渠道
|
||||
const failedSources = new Set<string>();
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
if (detail.failed) {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider } = getProviderDisplayParts(source, providerMap);
|
||||
if (provider) {
|
||||
failedSources.add(source);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 统计这些渠道的所有请求
|
||||
const stats: Record<string, FailureStat> = {};
|
||||
|
||||
Object.values(timeFilteredData.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
// 只统计有失败记录的渠道
|
||||
if (!failedSources.has(source)) return;
|
||||
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
|
||||
if (!stats[displayName]) {
|
||||
stats[displayName] = {
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
maskedKey: masked,
|
||||
failedCount: 0,
|
||||
lastFailTime: 0,
|
||||
models: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (detail.failed) {
|
||||
stats[displayName].failedCount++;
|
||||
if (timestamp > stats[displayName].lastFailTime) {
|
||||
stats[displayName].lastFailTime = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// 按模型统计
|
||||
if (!stats[displayName].models[modelName]) {
|
||||
stats[displayName].models[modelName] = {
|
||||
success: 0,
|
||||
failure: 0,
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
recentRequests: [],
|
||||
lastTimestamp: 0,
|
||||
};
|
||||
}
|
||||
stats[displayName].models[modelName].total++;
|
||||
if (detail.failed) {
|
||||
stats[displayName].models[modelName].failure++;
|
||||
} else {
|
||||
stats[displayName].models[modelName].success++;
|
||||
}
|
||||
stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp });
|
||||
if (timestamp > stats[displayName].models[modelName].lastTimestamp) {
|
||||
stats[displayName].models[modelName].lastTimestamp = timestamp;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 计算成功率并排序请求
|
||||
Object.values(stats).forEach((stat) => {
|
||||
Object.values(stat.models).forEach((model) => {
|
||||
model.successRate = model.total > 0
|
||||
? (model.success / model.total) * 100
|
||||
: 0;
|
||||
model.recentRequests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
model.recentRequests = model.recentRequests.slice(-12);
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(stats)
|
||||
.filter((stat) => stat.failedCount > 0)
|
||||
.sort((a, b) => b.failedCount - a.failedCount)
|
||||
.slice(0, 10);
|
||||
}, [timeFilteredData, providerMap]);
|
||||
|
||||
// 获取所有渠道和模型列表
|
||||
const { channels, models } = useMemo(() => {
|
||||
const channelSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
failureStats.forEach((stat) => {
|
||||
channelSet.add(stat.displayName);
|
||||
Object.keys(stat.models).forEach((model) => modelSet.add(model));
|
||||
});
|
||||
|
||||
return {
|
||||
channels: Array.from(channelSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
};
|
||||
}, [failureStats]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredStats = useMemo(() => {
|
||||
return failureStats.filter((stat) => {
|
||||
if (filterChannel && stat.displayName !== filterChannel) return false;
|
||||
if (filterModel && !stat.models[filterModel]) return false;
|
||||
return true;
|
||||
});
|
||||
}, [failureStats, filterChannel, filterModel]);
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (displayName: string) => {
|
||||
setExpandedChannel(expandedChannel === displayName ? null : displayName);
|
||||
};
|
||||
|
||||
// 获取主要失败模型(前2个,已禁用的排在后面)
|
||||
const getTopFailedModels = (source: string, modelsMap: Record<string, ModelFailureStat>) => {
|
||||
return Object.entries(modelsMap)
|
||||
.filter(([, stat]) => stat.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(source, a[0]);
|
||||
const bDisabled = isModelDisabled(source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// 开始禁用流程(阻止事件冒泡)
|
||||
const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDisableClick(source, model);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.failure.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel} value={channel}>{channel}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.channel.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{loading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredStats.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
|
||||
) : (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.failure.header_name')}</th>
|
||||
<th>{t('monitor.failure.header_count')}</th>
|
||||
<th>{t('monitor.failure.header_time')}</th>
|
||||
<th>{t('monitor.failure.header_models')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStats.map((stat) => {
|
||||
const topModels = getTopFailedModels(stat.source, stat.models);
|
||||
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
|
||||
|
||||
return (
|
||||
<Fragment key={stat.displayName}>
|
||||
<tr
|
||||
className={styles.expandable}
|
||||
onClick={() => toggleExpand(stat.displayName)}
|
||||
>
|
||||
<td>
|
||||
{stat.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{stat.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
stat.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
|
||||
<td>{formatTimestamp(stat.lastFailTime)}</td>
|
||||
<td>
|
||||
{topModels.map(([model, modelStat]) => {
|
||||
const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0);
|
||||
const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model;
|
||||
const disabled = isModelDisabled(stat.source, model);
|
||||
return (
|
||||
<span
|
||||
key={model}
|
||||
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
|
||||
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
|
||||
>
|
||||
{shortModel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{totalFailedModels > 2 && (
|
||||
<span className={styles.moreModelsHint}>
|
||||
+{totalFailedModels - 2}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedChannel === stat.displayName && (
|
||||
<tr key={`${stat.displayName}-detail`}>
|
||||
<td colSpan={4} className={styles.expandDetail}>
|
||||
<div className={styles.expandTableWrapper}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.channel.model')}</th>
|
||||
<th>{t('monitor.channel.header_count')}</th>
|
||||
<th>{t('monitor.channel.header_rate')}</th>
|
||||
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||
<th>{t('monitor.channel.header_recent')}</th>
|
||||
<th>{t('monitor.channel.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stat.models)
|
||||
.filter(([, m]) => m.failure > 0)
|
||||
.sort((a, b) => {
|
||||
const aDisabled = isModelDisabled(stat.source, a[0]);
|
||||
const bDisabled = isModelDisabled(stat.source, b[0]);
|
||||
// 已禁用的排在后面
|
||||
if (aDisabled !== bDisabled) {
|
||||
return aDisabled ? 1 : -1;
|
||||
}
|
||||
// 然后按失败数降序
|
||||
return b[1].failure - a[1].failure;
|
||||
})
|
||||
.map(([modelName, modelStat]) => {
|
||||
const disabled = isModelDisabled(stat.source, modelName);
|
||||
return (
|
||||
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||
<td>{modelName}</td>
|
||||
<td>{modelStat.total.toLocaleString()}</td>
|
||||
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||
{modelStat.successRate.toFixed(1)}%
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||
{' / '}
|
||||
<span className={styles.kpiFailure}>{modelStat.failure}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{modelStat.recentRequests.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||
<td>
|
||||
{disabled ? (
|
||||
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyModelChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'rgba(59, 130, 246, 0.7)', // 蓝色
|
||||
'rgba(34, 197, 94, 0.7)', // 绿色
|
||||
'rgba(249, 115, 22, 0.7)', // 橙色
|
||||
'rgba(139, 92, 246, 0.7)', // 紫色
|
||||
'rgba(236, 72, 153, 0.7)', // 粉色
|
||||
'rgba(6, 182, 212, 0.7)', // 青色
|
||||
];
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, successRates: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 收集每小时每个模型的请求数
|
||||
const hourlyStats: Record<string, Record<string, { success: number; failed: number }>> = {};
|
||||
const modelSet = new Set<string>();
|
||||
|
||||
// 初始化所有小时
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = {};
|
||||
});
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelSet.add(modelName);
|
||||
modelData.details.forEach((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = {};
|
||||
}
|
||||
if (!hourlyStats[hourKey][modelName]) {
|
||||
hourlyStats[hourKey][modelName] = { success: 0, failed: 0 };
|
||||
}
|
||||
if (detail.failed) {
|
||||
hourlyStats[hourKey][modelName].failed++;
|
||||
} else {
|
||||
hourlyStats[hourKey][modelName].success++;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
// 计算每个模型的总请求数,取 Top 6
|
||||
const modelTotals: Record<string, number> = {};
|
||||
hours.forEach((hour) => {
|
||||
Object.entries(hourlyStats[hour]).forEach(([model, stats]) => {
|
||||
modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed;
|
||||
});
|
||||
});
|
||||
|
||||
const topModels = Object.entries(modelTotals)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6)
|
||||
.map(([name]) => name);
|
||||
|
||||
// 构建每个模型的数据数组
|
||||
const modelData: Record<string, number[]> = {};
|
||||
topModels.forEach((model) => {
|
||||
modelData[model] = hours.map((hour) => {
|
||||
const stats = hourlyStats[hour][model];
|
||||
return stats ? stats.success + stats.failed : 0;
|
||||
});
|
||||
});
|
||||
|
||||
// 计算每小时的成功率
|
||||
const successRates = hours.map((hour) => {
|
||||
let totalSuccess = 0;
|
||||
let totalRequests = 0;
|
||||
Object.values(hourlyStats[hour]).forEach((stats) => {
|
||||
totalSuccess += stats.success;
|
||||
totalRequests += stats.success + stats.failed;
|
||||
});
|
||||
return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0;
|
||||
});
|
||||
|
||||
return { hours, models: topModels, modelData, successRates };
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
// 成功率折线放在最前面
|
||||
const datasets: any[] = [{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly.success_rate'),
|
||||
data: hourlyData.successRates,
|
||||
borderColor: '#4ef0c3',
|
||||
backgroundColor: '#4ef0c3',
|
||||
borderWidth: 2.5,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y1',
|
||||
stack: '',
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#4ef0c3',
|
||||
pointBorderColor: '#4ef0c3',
|
||||
}];
|
||||
|
||||
// 添加模型柱状图
|
||||
hourlyData.models.forEach((model, index) => {
|
||||
datasets.push({
|
||||
type: 'bar' as const,
|
||||
label: model,
|
||||
data: hourlyData.modelData[model],
|
||||
backgroundColor: COLORS[index % COLORS.length],
|
||||
borderColor: COLORS[index % COLORS.length],
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
stack: 'models',
|
||||
yAxisID: 'y',
|
||||
});
|
||||
});
|
||||
|
||||
return { labels, datasets };
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.requests'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y1: {
|
||||
position: 'right' as const,
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}%`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('monitor.hourly.success_rate'),
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface HourlyTokenChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
type HourRange = 6 | 12 | 24;
|
||||
|
||||
export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [hourRange, setHourRange] = useState<HourRange>(12);
|
||||
|
||||
// 按小时聚合 Token 数据
|
||||
const hourlyData = useMemo(() => {
|
||||
if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] };
|
||||
|
||||
const now = new Date();
|
||||
let cutoffTime: Date;
|
||||
let hoursCount: number;
|
||||
|
||||
cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000);
|
||||
cutoffTime.setMinutes(0, 0, 0);
|
||||
hoursCount = hourRange + 1;
|
||||
|
||||
// 生成所有小时的时间点
|
||||
const allHours: string[] = [];
|
||||
for (let i = 0; i < hoursCount; i++) {
|
||||
const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000);
|
||||
const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
allHours.push(hourKey);
|
||||
}
|
||||
|
||||
// 初始化所有小时的数据为0
|
||||
const hourlyStats: Record<string, {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
cached: number;
|
||||
}> = {};
|
||||
allHours.forEach((hour) => {
|
||||
hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
});
|
||||
|
||||
// 收集每小时的 Token 数据(只统计成功请求)
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
// 跳过失败请求,失败请求的 Token 数据不准确
|
||||
if (detail.failed) return;
|
||||
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
if (timestamp < cutoffTime) return;
|
||||
|
||||
const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH
|
||||
if (!hourlyStats[hourKey]) {
|
||||
hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 };
|
||||
}
|
||||
hourlyStats[hourKey].total += detail.tokens.total_tokens || 0;
|
||||
hourlyStats[hourKey].input += detail.tokens.input_tokens || 0;
|
||||
hourlyStats[hourKey].output += detail.tokens.output_tokens || 0;
|
||||
hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0;
|
||||
hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 获取排序后的小时列表
|
||||
const hours = allHours.sort();
|
||||
|
||||
return {
|
||||
hours,
|
||||
totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000),
|
||||
inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000),
|
||||
outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000),
|
||||
reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000),
|
||||
cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000),
|
||||
};
|
||||
}, [data, hourRange]);
|
||||
|
||||
// 获取时间范围标签
|
||||
const hourRangeLabel = useMemo(() => {
|
||||
if (hourRange === 6) return t('monitor.hourly.last_6h');
|
||||
if (hourRange === 12) return t('monitor.hourly.last_12h');
|
||||
return t('monitor.hourly.last_24h');
|
||||
}, [hourRange, t]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
const labels = hourlyData.hours.map((hour) => {
|
||||
const date = new Date(hour + ':00:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||
return `${date.getHours()}:00`;
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.input'),
|
||||
data: hourlyData.inputTokens,
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: '#22c55e',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#22c55e',
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: t('monitor.hourly_token.output'),
|
||||
data: hourlyData.outputTokens,
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: '#f97316',
|
||||
borderWidth: 2,
|
||||
tension: 0.4,
|
||||
yAxisID: 'y',
|
||||
order: 0,
|
||||
pointRadius: 3,
|
||||
pointBackgroundColor: '#f97316',
|
||||
},
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: t('monitor.hourly_token.total'),
|
||||
data: hourlyData.totalTokens,
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: 'rgba(59, 130, 246, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
yAxisID: 'y',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [hourlyData, t]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
usePointStyle: true,
|
||||
padding: 12,
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
generateLabels: (chart: any) => {
|
||||
return chart.data.datasets.map((dataset: any, i: number) => {
|
||||
const isLine = dataset.type === 'line';
|
||||
return {
|
||||
text: dataset.label,
|
||||
fillStyle: dataset.backgroundColor,
|
||||
strokeStyle: dataset.borderColor,
|
||||
lineWidth: 0,
|
||||
hidden: !chart.isDatasetVisible(i),
|
||||
datasetIndex: i,
|
||||
pointStyle: isLine ? 'circle' : 'rect',
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.raw;
|
||||
return `${label}: ${value.toFixed(1)}K`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
ticks: {
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
callback: (value: string | number) => `${value}K`,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Tokens (K)',
|
||||
color: isDark ? '#9ca3af' : '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark]);
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{hourRangeLabel}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(6)}
|
||||
>
|
||||
{t('monitor.hourly.last_6h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(12)}
|
||||
>
|
||||
{t('monitor.hourly.last_12h')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||
onClick={() => setHourRange(24)}
|
||||
>
|
||||
{t('monitor.hourly.last_24h')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.chartContent}>
|
||||
{loading || hourlyData.hours.length === 0 ? (
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
) : (
|
||||
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface KpiCardsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000000) {
|
||||
return (num / 1000000000).toFixed(2) + 'B';
|
||||
}
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function KpiCards({ data, loading, timeRange }: KpiCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 计算统计数据
|
||||
const stats = useMemo(() => {
|
||||
if (!data?.apis) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
successRate: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cachedTokens: 0,
|
||||
avgTpm: 0,
|
||||
avgRpm: 0,
|
||||
avgRpd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let totalRequests = 0;
|
||||
let successRequests = 0;
|
||||
let failedRequests = 0;
|
||||
let totalTokens = 0;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let reasoningTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
|
||||
// 收集所有时间戳用于计算 TPM/RPM
|
||||
const timestamps: number[] = [];
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.values(apiData.models).forEach((modelData) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
totalRequests++;
|
||||
if (detail.failed) {
|
||||
failedRequests++;
|
||||
} else {
|
||||
successRequests++;
|
||||
}
|
||||
|
||||
totalTokens += detail.tokens.total_tokens || 0;
|
||||
inputTokens += detail.tokens.input_tokens || 0;
|
||||
outputTokens += detail.tokens.output_tokens || 0;
|
||||
reasoningTokens += detail.tokens.reasoning_tokens || 0;
|
||||
cachedTokens += detail.tokens.cached_tokens || 0;
|
||||
|
||||
timestamps.push(new Date(detail.timestamp).getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0;
|
||||
|
||||
// 计算 TPM 和 RPM(基于实际时间跨度)
|
||||
let avgTpm = 0;
|
||||
let avgRpm = 0;
|
||||
let avgRpd = 0;
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1);
|
||||
const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1);
|
||||
|
||||
avgTpm = Math.round(totalTokens / timeSpanMinutes);
|
||||
avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10;
|
||||
avgRpd = Math.round(totalRequests / timeSpanDays);
|
||||
}
|
||||
|
||||
return {
|
||||
totalRequests,
|
||||
successRequests,
|
||||
failedRequests,
|
||||
successRate,
|
||||
totalTokens,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedTokens,
|
||||
avgTpm,
|
||||
avgRpm,
|
||||
avgRpd,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
return (
|
||||
<div className={styles.kpiGrid}>
|
||||
{/* 请求数 */}
|
||||
<div className={styles.kpiCard}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalRequests)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span className={styles.kpiSuccess}>
|
||||
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.kpiFailure}>
|
||||
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className={`${styles.kpiCard} ${styles.green}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.totalTokens)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
|
||||
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 TPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.purple}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgTpm)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.tokens_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 平均 RPM */}
|
||||
<div className={`${styles.kpiCard} ${styles.orange}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : stats.avgRpm.toFixed(1)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_minute')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日均 RPD */}
|
||||
<div className={`${styles.kpiCard} ${styles.cyan}`}>
|
||||
<div className={styles.kpiTitle}>
|
||||
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
|
||||
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>
|
||||
{loading ? '--' : formatNumber(stats.avgRpd)}
|
||||
</div>
|
||||
<div className={styles.kpiMeta}>
|
||||
<span>{t('monitor.kpi.requests_per_day')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface ModelDistributionChartProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
timeRange: number;
|
||||
}
|
||||
|
||||
// 颜色调色板
|
||||
const COLORS = [
|
||||
'#3b82f6', // 蓝色
|
||||
'#22c55e', // 绿色
|
||||
'#f97316', // 橙色
|
||||
'#8b5cf6', // 紫色
|
||||
'#ec4899', // 粉色
|
||||
'#06b6d4', // 青色
|
||||
'#eab308', // 黄色
|
||||
'#ef4444', // 红色
|
||||
'#14b8a6', // 青绿
|
||||
'#6366f1', // 靛蓝
|
||||
];
|
||||
|
||||
type ViewMode = 'request' | 'token';
|
||||
|
||||
export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('request');
|
||||
|
||||
const timeRangeLabel = timeRange === 1
|
||||
? t('monitor.today')
|
||||
: t('monitor.last_n_days', { n: timeRange });
|
||||
|
||||
// 计算模型分布数据
|
||||
const distributionData = useMemo(() => {
|
||||
if (!data?.apis) return [];
|
||||
|
||||
const modelStats: Record<string, { requests: number; tokens: number }> = {};
|
||||
|
||||
Object.values(data.apis).forEach((apiData) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelStats[modelName]) {
|
||||
modelStats[modelName] = { requests: 0, tokens: 0 };
|
||||
}
|
||||
modelData.details.forEach((detail) => {
|
||||
modelStats[modelName].requests++;
|
||||
modelStats[modelName].tokens += detail.tokens.total_tokens || 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 转换为数组并排序
|
||||
const sorted = Object.entries(modelStats)
|
||||
.map(([name, stats]) => ({
|
||||
name,
|
||||
requests: stats.requests,
|
||||
tokens: stats.tokens,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (viewMode === 'request') {
|
||||
return b.requests - a.requests;
|
||||
}
|
||||
return b.tokens - a.tokens;
|
||||
});
|
||||
|
||||
// 取 Top 10
|
||||
return sorted.slice(0, 10);
|
||||
}, [data, viewMode]);
|
||||
|
||||
// 计算总数
|
||||
const total = useMemo(() => {
|
||||
return distributionData.reduce((sum, item) => {
|
||||
return sum + (viewMode === 'request' ? item.requests : item.tokens);
|
||||
}, 0);
|
||||
}, [distributionData, viewMode]);
|
||||
|
||||
// 图表数据
|
||||
const chartData = useMemo(() => {
|
||||
return {
|
||||
labels: distributionData.map((item) => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: distributionData.map((item) =>
|
||||
viewMode === 'request' ? item.requests : item.tokens
|
||||
),
|
||||
backgroundColor: COLORS.slice(0, distributionData.length),
|
||||
borderColor: isDark ? '#1f2937' : '#ffffff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [distributionData, viewMode, isDark]);
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = useMemo(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '65%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDark ? '#374151' : '#ffffff',
|
||||
titleColor: isDark ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDark ? '#d1d5db' : '#4b5563',
|
||||
borderColor: isDark ? '#4b5563' : '#e5e7eb',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const value = context.raw;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
|
||||
if (viewMode === 'request') {
|
||||
return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`;
|
||||
}
|
||||
return `${value.toLocaleString()} tokens (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}), [isDark, total, viewMode, t]);
|
||||
|
||||
// 格式化数值
|
||||
const formatValue = (value: number) => {
|
||||
if (value >= 1000000) {
|
||||
return (value / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div>
|
||||
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
|
||||
<p className={styles.chartSubtitle}>
|
||||
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
|
||||
{' · Top 10'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.chartControls}>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('request')}
|
||||
>
|
||||
{t('monitor.distribution.requests')}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
|
||||
onClick={() => setViewMode('token')}
|
||||
>
|
||||
{t('monitor.distribution.tokens')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading || distributionData.length === 0 ? (
|
||||
<div className={styles.chartContent}>
|
||||
<div className={styles.chartEmpty}>
|
||||
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.distributionContent}>
|
||||
<div className={styles.donutWrapper}>
|
||||
<Doughnut data={chartData} options={chartOptions} />
|
||||
<div className={styles.donutCenter}>
|
||||
<div className={styles.donutLabel}>
|
||||
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.legendList}>
|
||||
{distributionData.map((item, index) => {
|
||||
const value = viewMode === 'request' ? item.requests : item.tokens;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
|
||||
return (
|
||||
<div key={item.name} className={styles.legendItem}>
|
||||
<span
|
||||
className={styles.legendDot}
|
||||
style={{ backgroundColor: COLORS[index] }}
|
||||
/>
|
||||
<span className={styles.legendName} title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className={styles.legendValue}>
|
||||
{formatValue(value)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { usageApi } from '@/services/api';
|
||||
import { useDisableModel } from '@/hooks';
|
||||
import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector';
|
||||
import { DisableModelModal } from './DisableModelModal';
|
||||
import { UnsupportedDisableModal } from './UnsupportedDisableModal';
|
||||
import {
|
||||
maskSecret,
|
||||
formatProviderDisplay,
|
||||
formatTimestamp,
|
||||
getRateClassName,
|
||||
getProviderDisplayParts,
|
||||
type DateRange,
|
||||
} from '@/utils/monitor';
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
interface RequestLogsProps {
|
||||
data: UsageData | null;
|
||||
loading: boolean;
|
||||
providerMap: Record<string, string>;
|
||||
providerTypeMap: Record<string, string>;
|
||||
apiFilter: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
timestampMs: number;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
source: string;
|
||||
displayName: string;
|
||||
providerName: string | null;
|
||||
providerType: string;
|
||||
maskedKey: string;
|
||||
failed: boolean;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
interface ChannelModelRequest {
|
||||
failed: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 预计算的统计数据缓存
|
||||
interface PrecomputedStats {
|
||||
recentRequests: ChannelModelRequest[];
|
||||
successRate: string;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// 虚拟滚动行高
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
export function RequestLogs({ data, loading: parentLoading, providerMap, providerTypeMap, apiFilter }: RequestLogsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [filterApi, setFilterApi] = useState('');
|
||||
const [filterModel, setFilterModel] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||
const [filterProviderType, setFilterProviderType] = useState('');
|
||||
const [autoRefresh, setAutoRefresh] = useState(10);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// 用 ref 存储 fetchLogData,避免作为定时器 useEffect 的依赖
|
||||
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// 虚拟滚动容器 ref
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
// 固定表头容器 ref
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 同步表头和内容的水平滚动
|
||||
const handleScroll = useCallback(() => {
|
||||
if (tableContainerRef.current && headerRef.current) {
|
||||
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 时间范围状态
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||
|
||||
// 日志独立数据状态
|
||||
const [logData, setLogData] = useState<UsageData | null>(null);
|
||||
const [logLoading, setLogLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
// 使用禁用模型 Hook
|
||||
const {
|
||||
disableState,
|
||||
unsupportedState,
|
||||
disabling,
|
||||
isModelDisabled,
|
||||
handleDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
handleCloseUnsupported,
|
||||
} = useDisableModel({ providerMap, providerTypeMap });
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => {
|
||||
setTimeRange(range);
|
||||
if (custom) {
|
||||
setCustomRange(custom);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 使用日志独立数据或父组件数据
|
||||
const effectiveData = logData || data;
|
||||
// 只在首次加载且没有数据时显示 loading 状态
|
||||
const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData);
|
||||
|
||||
// 当父组件数据加载完成时,标记首次加载完成
|
||||
useEffect(() => {
|
||||
if (!parentLoading && data) {
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
}, [parentLoading, data]);
|
||||
|
||||
// 独立获取日志数据
|
||||
const fetchLogData = useCallback(async () => {
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const response = await usageApi.getUsage();
|
||||
const usageData = response?.usage ?? response;
|
||||
|
||||
// 应用时间范围过滤
|
||||
if (usageData?.apis) {
|
||||
const apis = usageData.apis as UsageData['apis'];
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (timeRange === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof timeRange === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const filtered: UsageData = { apis: {} };
|
||||
|
||||
Object.entries(apis).forEach(([apiKey, apiData]) => {
|
||||
// 如果有 API 过滤器,检查是否匹配
|
||||
if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiData?.models) return;
|
||||
|
||||
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelData?.details || !Array.isArray(modelData.details)) return;
|
||||
|
||||
const filteredDetails = modelData.details.filter((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
|
||||
if (filteredDetails.length > 0) {
|
||||
filteredModels[modelName] = { details: filteredDetails };
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length > 0) {
|
||||
filtered.apis[apiKey] = { models: filteredModels };
|
||||
}
|
||||
});
|
||||
|
||||
setLogData(filtered);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('日志刷新失败:', err);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
}, [timeRange, customRange, apiFilter]);
|
||||
|
||||
// 同步 fetchLogData 到 ref,确保定时器始终调用最新版本
|
||||
useEffect(() => {
|
||||
fetchLogDataRef.current = fetchLogData;
|
||||
}, [fetchLogData]);
|
||||
|
||||
// 统一的自动刷新定时器管理
|
||||
useEffect(() => {
|
||||
// 清理旧定时器
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
|
||||
// 禁用自动刷新时
|
||||
if (autoRefresh <= 0) {
|
||||
setCountdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置初始倒计时
|
||||
setCountdown(autoRefresh);
|
||||
|
||||
// 创建新定时器
|
||||
countdownRef.current = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
// 倒计时结束,触发刷新并重置倒计时
|
||||
fetchLogDataRef.current();
|
||||
return autoRefresh;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 组件卸载或 autoRefresh 变化时清理
|
||||
return () => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh]);
|
||||
|
||||
// 时间范围变化时立即刷新数据
|
||||
useEffect(() => {
|
||||
fetchLogData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, customRange]);
|
||||
|
||||
// 获取倒计时显示文本
|
||||
const getCountdownText = () => {
|
||||
if (logLoading) {
|
||||
return t('monitor.logs.refreshing');
|
||||
}
|
||||
if (autoRefresh === 0) {
|
||||
return t('monitor.logs.manual_refresh');
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return t('monitor.logs.refresh_in_seconds', { seconds: countdown });
|
||||
}
|
||||
return t('monitor.logs.refreshing');
|
||||
};
|
||||
|
||||
// 将数据转换为日志条目
|
||||
const logEntries = useMemo(() => {
|
||||
if (!effectiveData?.apis) return [];
|
||||
|
||||
const entries: LogEntry[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => {
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
modelData.details.forEach((detail) => {
|
||||
const source = detail.source || 'unknown';
|
||||
const { provider, masked } = getProviderDisplayParts(source, providerMap);
|
||||
const displayName = provider ? `${provider} (${masked})` : masked;
|
||||
const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0;
|
||||
// 获取提供商类型
|
||||
const providerType = providerTypeMap[source] || '--';
|
||||
entries.push({
|
||||
id: `${idCounter++}`,
|
||||
timestamp: detail.timestamp,
|
||||
timestampMs,
|
||||
apiKey,
|
||||
model: modelName,
|
||||
source,
|
||||
displayName,
|
||||
providerName: provider,
|
||||
providerType,
|
||||
maskedKey: masked,
|
||||
failed: detail.failed,
|
||||
inputTokens: detail.tokens.input_tokens || 0,
|
||||
outputTokens: detail.tokens.output_tokens || 0,
|
||||
totalTokens: detail.tokens.total_tokens || 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 按时间倒序排序
|
||||
return entries.sort((a, b) => b.timestampMs - a.timestampMs);
|
||||
}, [effectiveData, providerMap, providerTypeMap]);
|
||||
|
||||
// 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算)
|
||||
const precomputedStats = useMemo(() => {
|
||||
const statsMap = new Map<string, PrecomputedStats>();
|
||||
|
||||
// 首先按渠道+模型分组,并按时间排序
|
||||
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
|
||||
|
||||
logEntries.forEach((entry, index) => {
|
||||
const key = `${entry.source}|||${entry.model}`;
|
||||
if (!channelModelGroups[key]) {
|
||||
channelModelGroups[key] = [];
|
||||
}
|
||||
channelModelGroups[key].push({ entry, index });
|
||||
});
|
||||
|
||||
// 对每个分组按时间正序排序(用于计算累计统计)
|
||||
Object.values(channelModelGroups).forEach((group) => {
|
||||
group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs);
|
||||
});
|
||||
|
||||
// 计算每个条目的统计数据
|
||||
Object.entries(channelModelGroups).forEach(([, group]) => {
|
||||
let successCount = 0;
|
||||
let totalCount = 0;
|
||||
const recentRequests: ChannelModelRequest[] = [];
|
||||
|
||||
group.forEach(({ entry }) => {
|
||||
totalCount++;
|
||||
if (!entry.failed) {
|
||||
successCount++;
|
||||
}
|
||||
|
||||
// 维护最近 10 次请求
|
||||
recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs });
|
||||
if (recentRequests.length > 10) {
|
||||
recentRequests.shift();
|
||||
}
|
||||
|
||||
// 计算成功率
|
||||
const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// 存储该条目的统计数据
|
||||
statsMap.set(entry.id, {
|
||||
recentRequests: [...recentRequests],
|
||||
successRate,
|
||||
totalCount,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return statsMap;
|
||||
}, [logEntries]);
|
||||
|
||||
// 获取筛选选项
|
||||
const { apis, models, sources, providerTypes } = useMemo(() => {
|
||||
const apiSet = new Set<string>();
|
||||
const modelSet = new Set<string>();
|
||||
const sourceSet = new Set<string>();
|
||||
const providerTypeSet = new Set<string>();
|
||||
|
||||
logEntries.forEach((entry) => {
|
||||
apiSet.add(entry.apiKey);
|
||||
modelSet.add(entry.model);
|
||||
sourceSet.add(entry.source);
|
||||
if (entry.providerType && entry.providerType !== '--') {
|
||||
providerTypeSet.add(entry.providerType);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
apis: Array.from(apiSet).sort(),
|
||||
models: Array.from(modelSet).sort(),
|
||||
sources: Array.from(sourceSet).sort(),
|
||||
providerTypes: Array.from(providerTypeSet).sort(),
|
||||
};
|
||||
}, [logEntries]);
|
||||
|
||||
// 过滤后的数据
|
||||
const filteredEntries = useMemo(() => {
|
||||
return logEntries.filter((entry) => {
|
||||
if (filterApi && entry.apiKey !== filterApi) return false;
|
||||
if (filterModel && entry.model !== filterModel) return false;
|
||||
if (filterSource && entry.source !== filterSource) return false;
|
||||
if (filterStatus === 'success' && entry.failed) return false;
|
||||
if (filterStatus === 'failed' && !entry.failed) return false;
|
||||
if (filterProviderType && entry.providerType !== filterProviderType) return false;
|
||||
return true;
|
||||
});
|
||||
}, [logEntries, filterApi, filterModel, filterSource, filterStatus, filterProviderType]);
|
||||
|
||||
// 虚拟滚动配置
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredEntries.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10, // 预渲染上下各 10 行
|
||||
});
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 获取预计算的统计数据
|
||||
const getStats = (entry: LogEntry): PrecomputedStats => {
|
||||
return precomputedStats.get(entry.id) || {
|
||||
recentRequests: [],
|
||||
successRate: '0.0',
|
||||
totalCount: 0,
|
||||
};
|
||||
};
|
||||
|
||||
// 渲染单行
|
||||
const renderRow = (entry: LogEntry) => {
|
||||
const stats = getStats(entry);
|
||||
const rateValue = parseFloat(stats.successRate);
|
||||
const disabled = isModelDisabled(entry.source, entry.model);
|
||||
|
||||
return (
|
||||
<>
|
||||
<td title={entry.apiKey}>
|
||||
{maskSecret(entry.apiKey)}
|
||||
</td>
|
||||
<td>{entry.providerType}</td>
|
||||
<td title={entry.model}>
|
||||
{entry.model}
|
||||
</td>
|
||||
<td title={entry.source}>
|
||||
{entry.providerName ? (
|
||||
<>
|
||||
<span className={styles.channelName}>{entry.providerName}</span>
|
||||
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
|
||||
</>
|
||||
) : (
|
||||
entry.maskedKey
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
|
||||
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.statusBars}>
|
||||
{stats.recentRequests.map((req, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className={getRateClassName(rateValue, styles)}>
|
||||
{stats.successRate}%
|
||||
</td>
|
||||
<td>{formatNumber(stats.totalCount)}</td>
|
||||
<td>{formatNumber(entry.inputTokens)}</td>
|
||||
<td>{formatNumber(entry.outputTokens)}</td>
|
||||
<td>{formatNumber(entry.totalTokens)}</td>
|
||||
<td>{formatTimestamp(entry.timestamp)}</td>
|
||||
<td>
|
||||
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
|
||||
disabled ? (
|
||||
<span className={styles.disabledLabel}>
|
||||
{t('monitor.logs.disabled')}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={styles.disableBtn}
|
||||
title={t('monitor.logs.disable_model')}
|
||||
onClick={() => handleDisableClick(entry.source, entry.model)}
|
||||
>
|
||||
{t('monitor.logs.disable')}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={t('monitor.logs.title')}
|
||||
subtitle={
|
||||
<span>
|
||||
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
|
||||
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
customRange={customRange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* 筛选器 */}
|
||||
<div className={styles.logFilters}>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterApi}
|
||||
onChange={(e) => setFilterApi(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_apis')}</option>
|
||||
{apis.map((api) => (
|
||||
<option key={api} value={api}>
|
||||
{maskSecret(api)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterProviderType}
|
||||
onChange={(e) => setFilterProviderType(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_provider_types')}</option>
|
||||
{providerTypes.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterModel}
|
||||
onChange={(e) => setFilterModel(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_models')}</option>
|
||||
{models.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterSource}
|
||||
onChange={(e) => setFilterSource(e.target.value)}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_sources')}</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{formatProviderDisplay(source, providerMap)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||
>
|
||||
<option value="">{t('monitor.logs.all_status')}</option>
|
||||
<option value="success">{t('monitor.logs.success')}</option>
|
||||
<option value="failed">{t('monitor.logs.failed')}</option>
|
||||
</select>
|
||||
|
||||
<span className={styles.logLastUpdate}>
|
||||
{getCountdownText()}
|
||||
</span>
|
||||
|
||||
<select
|
||||
className={styles.logSelect}
|
||||
value={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(Number(e.target.value))}
|
||||
>
|
||||
<option value="0">{t('monitor.logs.manual_refresh')}</option>
|
||||
<option value="5">{t('monitor.logs.refresh_5s')}</option>
|
||||
<option value="10">{t('monitor.logs.refresh_10s')}</option>
|
||||
<option value="15">{t('monitor.logs.refresh_15s')}</option>
|
||||
<option value="30">{t('monitor.logs.refresh_30s')}</option>
|
||||
<option value="60">{t('monitor.logs.refresh_60s')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动表格 */}
|
||||
<div className={styles.tableWrapper}>
|
||||
{showLoading ? (
|
||||
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 固定表头 */}
|
||||
<div ref={headerRef} className={styles.stickyHeader}>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('monitor.logs.header_api')}</th>
|
||||
<th>{t('monitor.logs.header_request_type')}</th>
|
||||
<th>{t('monitor.logs.header_model')}</th>
|
||||
<th>{t('monitor.logs.header_source')}</th>
|
||||
<th>{t('monitor.logs.header_status')}</th>
|
||||
<th>{t('monitor.logs.header_recent')}</th>
|
||||
<th>{t('monitor.logs.header_rate')}</th>
|
||||
<th>{t('monitor.logs.header_count')}</th>
|
||||
<th>{t('monitor.logs.header_input')}</th>
|
||||
<th>{t('monitor.logs.header_output')}</th>
|
||||
<th>{t('monitor.logs.header_total')}</th>
|
||||
<th>{t('monitor.logs.header_time')}</th>
|
||||
<th>{t('monitor.logs.header_actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动容器 */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={styles.virtualScrollContainer}
|
||||
style={{
|
||||
height: 'calc(100vh - 420px)',
|
||||
minHeight: '360px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||
<tbody>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = filteredEntries[virtualRow.index];
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
display: 'table',
|
||||
tableLayout: 'fixed',
|
||||
}}
|
||||
>
|
||||
{renderRow(entry)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{filteredEntries.length > 0 && (
|
||||
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
{t('monitor.logs.total_count', { count: filteredEntries.length })}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 禁用确认弹窗 */}
|
||||
<DisableModelModal
|
||||
disableState={disableState}
|
||||
disabling={disabling}
|
||||
onConfirm={handleConfirmDisable}
|
||||
onCancel={handleCancelDisable}
|
||||
/>
|
||||
|
||||
{/* 不支持自动禁用提示弹窗 */}
|
||||
<UnsupportedDisableModal
|
||||
state={unsupportedState}
|
||||
onClose={handleCloseUnsupported}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from '@/pages/MonitorPage.module.scss';
|
||||
|
||||
export type TimeRange = 1 | 7 | 14 | 30 | 'custom';
|
||||
|
||||
interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface TimeRangeSelectorProps {
|
||||
value: TimeRange;
|
||||
onChange: (range: TimeRange, customRange?: DateRange) => void;
|
||||
customRange?: DateRange;
|
||||
}
|
||||
|
||||
export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showCustom, setShowCustom] = useState(value === 'custom');
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
if (customRange?.start) {
|
||||
return formatDateForInput(customRange.start);
|
||||
}
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return formatDateForInput(date);
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
if (customRange?.end) {
|
||||
return formatDateForInput(customRange.end);
|
||||
}
|
||||
return formatDateForInput(new Date());
|
||||
});
|
||||
|
||||
const handleTimeClick = useCallback((range: TimeRange) => {
|
||||
if (range === 'custom') {
|
||||
setShowCustom(true);
|
||||
onChange(range);
|
||||
} else {
|
||||
setShowCustom(false);
|
||||
onChange(range);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const handleApplyCustom = useCallback(() => {
|
||||
if (startDate && endDate) {
|
||||
const start = new Date(startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
if (start <= end) {
|
||||
onChange('custom', { start, end });
|
||||
}
|
||||
}
|
||||
}, [startDate, endDate, onChange]);
|
||||
|
||||
return (
|
||||
<div className={styles.timeRangeSelector}>
|
||||
<div className={styles.timeButtons}>
|
||||
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
|
||||
onClick={() => handleTimeClick(range)}
|
||||
>
|
||||
{range === 1
|
||||
? t('monitor.time.today')
|
||||
: range === 'custom'
|
||||
? t('monitor.time.custom')
|
||||
: t('monitor.time.last_n_days', { n: range })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showCustom && (
|
||||
<div className={styles.customDatePicker}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.dateInput}
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
|
||||
{t('monitor.time.apply')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateForInput(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 根据时间范围过滤数据的工具函数
|
||||
export function filterByTimeRange<T extends { timestamp?: string }>(
|
||||
items: T[],
|
||||
range: TimeRange,
|
||||
customRange?: DateRange
|
||||
): T[] {
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (range === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof range === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
// 默认7天
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.timestamp) return false;
|
||||
const timestamp = new Date(item.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// 格式化时间范围显示
|
||||
export function formatTimeRangeCaption(
|
||||
range: TimeRange,
|
||||
customRange?: DateRange,
|
||||
t?: (key: string, options?: any) => string
|
||||
): string {
|
||||
if (range === 'custom' && customRange) {
|
||||
const startStr = formatDateForDisplay(customRange.start);
|
||||
const endStr = formatDateForDisplay(customRange.end);
|
||||
return `${startStr} - ${endStr}`;
|
||||
}
|
||||
if (range === 1) {
|
||||
return t ? t('monitor.time.today') : '今天';
|
||||
}
|
||||
return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range} 天`;
|
||||
}
|
||||
|
||||
function formatDateForDisplay(date: Date): string {
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${month}/${day}`;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 不支持自动禁用提示弹窗组件
|
||||
* 显示手动操作指南
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { UnsupportedDisableState } from '@/hooks/useDisableModel';
|
||||
|
||||
interface UnsupportedDisableModalProps {
|
||||
/** 不支持禁用的状态 */
|
||||
state: UnsupportedDisableState | null;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UnsupportedDisableModal({
|
||||
state,
|
||||
onClose,
|
||||
}: UnsupportedDisableModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!state}
|
||||
onClose={onClose}
|
||||
title={t('monitor.logs.disable_unsupported_title')}
|
||||
width={450}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{/* 提示信息 */}
|
||||
<p style={{
|
||||
marginBottom: 16,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--warning-color, #f59e0b)',
|
||||
fontWeight: 500,
|
||||
}}>
|
||||
⚠️ {t('monitor.logs.disable_unsupported_desc', { providerType: state.providerType })}
|
||||
</p>
|
||||
|
||||
{/* 手动操作指南 */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<p style={{
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
color: 'var(--text-primary)',
|
||||
}}>
|
||||
{t('monitor.logs.disable_unsupported_guide_title')}
|
||||
</p>
|
||||
<ul style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.8,
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step1')}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step2', { providerType: state.providerType })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step3', { model: state.model })}</li>
|
||||
<li>{t('monitor.logs.disable_unsupported_guide_step4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
{t('monitor.logs.disable_unsupported_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export { KpiCards } from './KpiCards';
|
||||
export { ModelDistributionChart } from './ModelDistributionChart';
|
||||
export { DailyTrendChart } from './DailyTrendChart';
|
||||
export { HourlyModelChart } from './HourlyModelChart';
|
||||
export { HourlyTokenChart } from './HourlyTokenChart';
|
||||
export { ChannelStats } from './ChannelStats';
|
||||
export { FailureAnalysis } from './FailureAnalysis';
|
||||
export { RequestLogs } from './RequestLogs';
|
||||
@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
|
||||
|
||||
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
|
||||
const clearCache = useConfigStore((state) => state.clearCache);
|
||||
@@ -81,32 +81,34 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
||||
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
|
||||
|
||||
const clearAmpcodeUpstreamApiKey = async () => {
|
||||
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
|
||||
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await ampcodeApi.clearUpstreamApiKey();
|
||||
const previous = config?.ampcode ?? {};
|
||||
const next: AmpcodeConfig = { ...previous };
|
||||
delete next.upstreamApiKey;
|
||||
updateConfigValue('ampcode', next);
|
||||
clearCache('ampcode');
|
||||
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
setError(message);
|
||||
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const performSaveAmpcode = async () => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
|
||||
}
|
||||
};
|
||||
|
||||
const saveAmpcode = async () => {
|
||||
if (!loaded && mappingsDirty) {
|
||||
showConfirmation({
|
||||
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
|
||||
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
|
||||
variant: 'secondary', // Not dangerous, just a warning
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: performSaveAmpcode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performSaveAmpcode();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconClaude from '@/assets/icons/claude.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -55,11 +60,19 @@ export function ClaudeSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -99,12 +112,11 @@ export function ClaudeSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -58,11 +63,19 @@ export function CodexSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -106,12 +119,11 @@ export function CodexSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -6,7 +6,12 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import iconGemini from '@/assets/icons/gemini.svg';
|
||||
import type { GeminiKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import type { GeminiFormState } from '../types';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
@@ -55,11 +60,19 @@ export function GeminiSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -99,12 +112,11 @@ export function GeminiSection({
|
||||
/>
|
||||
)}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
|
||||
const excludedModels = item.excludedModels ?? [];
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -7,7 +7,12 @@ import iconOpenaiLight from '@/assets/icons/openai-light.svg';
|
||||
import iconOpenaiDark from '@/assets/icons/openai-dark.svg';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -57,8 +62,15 @@ export function OpenAISection({
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
|
||||
configs.forEach((provider) => {
|
||||
const allKeys = (provider.apiKeyEntries || []).map((entry) => entry.apiKey).filter(Boolean);
|
||||
const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source));
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => sourceIds.add(id));
|
||||
(provider.apiKeyEntries || []).forEach((entry) => {
|
||||
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
const filteredDetails = sourceIds.size
|
||||
? usageDetails.filter((detail) => sourceIds.has(detail.source))
|
||||
: [];
|
||||
cache.set(provider.name, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
@@ -96,7 +108,7 @@ export function OpenAISection({
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item) => {
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, maskApiKey);
|
||||
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const apiKeyEntries = item.apiKeyEntries || [];
|
||||
const statusData = statusBarCache.get(item.name) || calculateStatusBarData([]);
|
||||
@@ -130,7 +142,7 @@ export function OpenAISection({
|
||||
</div>
|
||||
<div className={styles.apiKeyEntryList}>
|
||||
{apiKeyEntries.map((entry, entryIndex) => {
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats, maskApiKey);
|
||||
const entryStats = getStatsBySource(entry.apiKey, keyStats);
|
||||
return (
|
||||
<div key={entryIndex} className={styles.apiKeyEntryCard}>
|
||||
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
|
||||
|
||||
@@ -5,7 +5,12 @@ import { Card } from '@/components/ui/Card';
|
||||
import iconVertex from '@/assets/icons/vertex.svg';
|
||||
import type { ProviderKeyConfig } from '@/types';
|
||||
import { maskApiKey } from '@/utils/format';
|
||||
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
|
||||
import {
|
||||
buildCandidateUsageSourceIds,
|
||||
calculateStatusBarData,
|
||||
type KeyStats,
|
||||
type UsageDetail,
|
||||
} from '@/utils/usage';
|
||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import { ProviderStatusBar } from '../ProviderStatusBar';
|
||||
@@ -51,11 +56,19 @@ export function VertexSection({
|
||||
|
||||
const statusBarCache = useMemo(() => {
|
||||
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
|
||||
const allApiKeys = new Set<string>();
|
||||
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
|
||||
allApiKeys.forEach((apiKey) => {
|
||||
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
|
||||
|
||||
configs.forEach((config) => {
|
||||
if (!config.apiKey) return;
|
||||
const candidates = buildCandidateUsageSourceIds({
|
||||
apiKey: config.apiKey,
|
||||
prefix: config.prefix,
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
const candidateSet = new Set(candidates);
|
||||
const filteredDetails = usageDetails.filter((detail) => candidateSet.has(detail.source));
|
||||
cache.set(config.apiKey, calculateStatusBarData(filteredDetails));
|
||||
});
|
||||
|
||||
return cache;
|
||||
}, [configs, usageDetails]);
|
||||
|
||||
@@ -86,10 +99,9 @@ export function VertexSection({
|
||||
onDelete={onDelete}
|
||||
actionsDisabled={actionsDisabled}
|
||||
renderContent={(item, index) => {
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
|
||||
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
|
||||
const headerEntries = Object.entries(item.headers || {});
|
||||
const statusData =
|
||||
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
|
||||
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types';
|
||||
import type { KeyStatBucket, KeyStats } from '@/utils/usage';
|
||||
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
|
||||
import type { AmpcodeFormState, ModelEntry } from './types';
|
||||
|
||||
export const DISABLE_ALL_MODELS_RULE = '*';
|
||||
@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
|
||||
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
|
||||
const trimmed = normalizeOpenAIBaseUrl(baseUrl);
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
||||
return `${trimmed}/models`;
|
||||
};
|
||||
|
||||
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
@@ -62,33 +62,50 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
|
||||
export const getStatsBySource = (
|
||||
apiKey: string,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
prefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
const masked = maskFn(apiKey);
|
||||
return bySource[apiKey] || bySource[masked] || { success: 0, failure: 0 };
|
||||
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
|
||||
if (!candidates.length) {
|
||||
return { success: 0, failure: 0 };
|
||||
}
|
||||
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
candidates.forEach((candidate) => {
|
||||
const stats = bySource[candidate];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
|
||||
export const getOpenAIProviderStats = (
|
||||
apiKeyEntries: ApiKeyEntry[] | undefined,
|
||||
keyStats: KeyStats,
|
||||
maskFn: (key: string) => string
|
||||
providerPrefix?: string
|
||||
): KeyStatBucket => {
|
||||
const bySource = keyStats.bySource ?? {};
|
||||
let totalSuccess = 0;
|
||||
let totalFailure = 0;
|
||||
|
||||
const sourceIds = new Set<string>();
|
||||
buildCandidateUsageSourceIds({ prefix: providerPrefix }).forEach((id) => sourceIds.add(id));
|
||||
(apiKeyEntries || []).forEach((entry) => {
|
||||
const key = entry?.apiKey || '';
|
||||
if (!key) return;
|
||||
const masked = maskFn(key);
|
||||
const stats = bySource[key] || bySource[masked] || { success: 0, failure: 0 };
|
||||
totalSuccess += stats.success;
|
||||
totalFailure += stats.failure;
|
||||
buildCandidateUsageSourceIds({ apiKey: entry?.apiKey }).forEach((id) => sourceIds.add(id));
|
||||
});
|
||||
|
||||
return { success: totalSuccess, failure: totalFailure };
|
||||
let success = 0;
|
||||
let failure = 0;
|
||||
sourceIds.forEach((id) => {
|
||||
const stats = bySource[id];
|
||||
if (!stats) return;
|
||||
success += stats.success;
|
||||
failure += stats.failure;
|
||||
});
|
||||
|
||||
return { success, failure };
|
||||
};
|
||||
|
||||
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
|
||||
|
||||
175
src/components/ui/AutocompleteInput.tsx
Normal file
175
src/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { IconChevronDown } from './icons';
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: string[] | { value: string; label?: string }[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
wrapperStyle?: React.CSSProperties;
|
||||
id?: string;
|
||||
rightElement?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder,
|
||||
disabled,
|
||||
hint,
|
||||
error,
|
||||
className = '',
|
||||
wrapperClassName = '',
|
||||
wrapperStyle,
|
||||
id,
|
||||
rightElement
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const normalizedOptions = options.map(opt =>
|
||||
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
|
||||
);
|
||||
|
||||
const filteredOptions = normalizedOptions.filter(opt => {
|
||||
const v = value.toLowerCase();
|
||||
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||
e.preventDefault();
|
||||
handleSelect(filteredOptions[highlightedIndex].value);
|
||||
} else if (isOpen) {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
} else if (e.key === 'Tab') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={id}
|
||||
className={`input ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
style={{ paddingRight: 32 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: disabled ? 'none' : 'auto',
|
||||
cursor: 'pointer',
|
||||
height: '100%'
|
||||
}}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
{rightElement}
|
||||
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
|
||||
</div>
|
||||
|
||||
{isOpen && filteredOptions.length > 0 && !disabled && (
|
||||
<div className="autocomplete-dropdown" style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
{filteredOptions.map((opt, index) => (
|
||||
<div
|
||||
key={`${opt.value}-${index}`}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{opt.value}</span>
|
||||
{opt.label && opt.label !== opt.value && (
|
||||
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hint && <div className="hint">{hint}</div>}
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,16 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ title, subtitle, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||
return (
|
||||
<div className={className ? `card ${className}` : 'card'}>
|
||||
{(title || extra) && (
|
||||
<div className="card-header">
|
||||
<div className="card-title-group">
|
||||
<div className="title">{title}</div>
|
||||
{subtitle && <div className="subtitle">{subtitle}</div>}
|
||||
</div>
|
||||
<div className="title">{title}</div>
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
width?: number | string;
|
||||
closeDisabled?: boolean;
|
||||
}
|
||||
|
||||
const CLOSE_ANIMATION_DURATION = 350;
|
||||
@@ -32,7 +33,15 @@ const unlockScroll = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export function Modal({ open, title, onClose, footer, width = 520, children }: PropsWithChildren<ModalProps>) {
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
footer,
|
||||
width = 520,
|
||||
closeDisabled = false,
|
||||
children
|
||||
}: PropsWithChildren<ModalProps>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -106,7 +115,13 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
|
||||
const modalContent = (
|
||||
<div className={overlayClass}>
|
||||
<div className={modalClass} style={{ width }} role="dialog" aria-modal="true">
|
||||
<button className="modal-close-floating" onClick={handleClose} aria-label="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-floating"
|
||||
onClick={closeDisabled ? undefined : handleClose}
|
||||
aria-label="Close"
|
||||
disabled={closeDisabled}
|
||||
>
|
||||
<IconX size={20} />
|
||||
</button>
|
||||
<div className="modal-header">
|
||||
|
||||
@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
disabled?: boolean;
|
||||
labelPosition?: 'left' | 'right';
|
||||
}
|
||||
@@ -12,6 +13,7 @@ export function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
labelPosition = 'right'
|
||||
}: ToggleSwitchProps) {
|
||||
@@ -25,7 +27,13 @@ export function ToggleSwitch({
|
||||
|
||||
return (
|
||||
<label className={className}>
|
||||
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<span className="track">
|
||||
<span className="thumb" />
|
||||
</span>
|
||||
|
||||
@@ -314,11 +314,3 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconActivity({ size = 20, ...props }: IconProps) {
|
||||
return (
|
||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,3 @@ export { useInterval } from './useInterval';
|
||||
export { useMediaQuery } from './useMediaQuery';
|
||||
export { usePagination } from './usePagination';
|
||||
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||
export { useDisableModel } from './useDisableModel';
|
||||
export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel';
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 禁用模型 Hook
|
||||
* 封装禁用模型的状态管理和业务逻辑
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { providersApi } from '@/services/api';
|
||||
import { useDisabledModelsStore } from '@/stores';
|
||||
import {
|
||||
resolveProvider,
|
||||
createDisableState,
|
||||
type DisableState,
|
||||
} from '@/utils/monitor';
|
||||
import type { OpenAIProviderConfig } from '@/types';
|
||||
|
||||
// 不支持禁用的渠道类型(小写)
|
||||
const UNSUPPORTED_PROVIDER_TYPES = ['claude', 'gemini', 'codex', 'vertex'];
|
||||
|
||||
/**
|
||||
* 不支持禁用的提示状态
|
||||
*/
|
||||
export interface UnsupportedDisableState {
|
||||
providerType: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface UseDisableModelOptions {
|
||||
providerMap: Record<string, string>;
|
||||
providerTypeMap?: Record<string, string>;
|
||||
providerModels?: Record<string, Set<string>>;
|
||||
}
|
||||
|
||||
export interface UseDisableModelReturn {
|
||||
/** 当前禁用状态 */
|
||||
disableState: DisableState | null;
|
||||
/** 不支持禁用的提示状态 */
|
||||
unsupportedState: UnsupportedDisableState | null;
|
||||
/** 是否正在禁用中 */
|
||||
disabling: boolean;
|
||||
/** 开始禁用流程 */
|
||||
handleDisableClick: (source: string, model: string) => void;
|
||||
/** 确认禁用(需要点击3次) */
|
||||
handleConfirmDisable: () => Promise<void>;
|
||||
/** 取消禁用 */
|
||||
handleCancelDisable: () => void;
|
||||
/** 关闭不支持提示 */
|
||||
handleCloseUnsupported: () => void;
|
||||
/** 检查模型是否已禁用 */
|
||||
isModelDisabled: (source: string, model: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用模型 Hook
|
||||
* @param options 配置选项
|
||||
* @returns 禁用模型相关的状态和方法
|
||||
*/
|
||||
export function useDisableModel(options: UseDisableModelOptions): UseDisableModelReturn {
|
||||
const { providerMap, providerTypeMap, providerModels } = options;
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 使用全局 store 管理禁用状态
|
||||
const { addDisabledModel, isDisabled } = useDisabledModelsStore();
|
||||
|
||||
const [disableState, setDisableState] = useState<DisableState | null>(null);
|
||||
const [unsupportedState, setUnsupportedState] = useState<UnsupportedDisableState | null>(null);
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
|
||||
// 开始禁用流程
|
||||
const handleDisableClick = useCallback((source: string, model: string) => {
|
||||
// 首先检查提供商类型是否支持禁用
|
||||
const providerType = providerTypeMap?.[source] || '';
|
||||
const lowerType = providerType.toLowerCase();
|
||||
|
||||
// 如果是不支持的类型,立即显示提示
|
||||
if (lowerType && UNSUPPORTED_PROVIDER_TYPES.includes(lowerType)) {
|
||||
const providerName = resolveProvider(source, providerMap);
|
||||
const displayName = providerName
|
||||
? `${providerName} / ${model}`
|
||||
: `${source.slice(0, 8)}*** / ${model}`;
|
||||
setUnsupportedState({
|
||||
providerType,
|
||||
model,
|
||||
displayName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 支持的类型,进入正常禁用流程
|
||||
setDisableState(createDisableState(source, model, providerMap));
|
||||
}, [providerMap, providerTypeMap]);
|
||||
|
||||
// 确认禁用(需要点击3次)
|
||||
const handleConfirmDisable = useCallback(async () => {
|
||||
if (!disableState) return;
|
||||
|
||||
// 前两次点击只增加步骤
|
||||
if (disableState.step < 3) {
|
||||
setDisableState({
|
||||
...disableState,
|
||||
step: disableState.step + 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 第3次点击,执行禁用
|
||||
setDisabling(true);
|
||||
try {
|
||||
const providerName = resolveProvider(disableState.source, providerMap);
|
||||
if (!providerName) {
|
||||
throw new Error(t('monitor.logs.disable_error_no_provider'));
|
||||
}
|
||||
|
||||
// 获取当前配置
|
||||
const providers = await providersApi.getOpenAIProviders();
|
||||
const targetProvider = providers.find(
|
||||
(p) => p.name && p.name.toLowerCase() === providerName.toLowerCase()
|
||||
);
|
||||
|
||||
if (!targetProvider) {
|
||||
throw new Error(t('monitor.logs.disable_error_provider_not_found', { provider: providerName }));
|
||||
}
|
||||
|
||||
const originalModels = targetProvider.models || [];
|
||||
const modelAlias = disableState.model;
|
||||
|
||||
// 过滤掉匹配的模型
|
||||
const filteredModels = originalModels.filter(
|
||||
(m) => m.alias !== modelAlias && m.name !== modelAlias
|
||||
);
|
||||
|
||||
// 只有当模型确实被过滤掉时才调用 API
|
||||
if (filteredModels.length < originalModels.length) {
|
||||
await providersApi.patchOpenAIProviderByName(targetProvider.name, {
|
||||
models: filteredModels,
|
||||
} as Partial<OpenAIProviderConfig>);
|
||||
}
|
||||
|
||||
// 标记为已禁用(全局状态)
|
||||
addDisabledModel(disableState.source, disableState.model);
|
||||
setDisableState(null);
|
||||
} catch (err) {
|
||||
console.error('禁用模型失败:', err);
|
||||
alert(err instanceof Error ? err.message : t('monitor.logs.disable_error'));
|
||||
} finally {
|
||||
setDisabling(false);
|
||||
}
|
||||
}, [disableState, providerMap, t, addDisabledModel]);
|
||||
|
||||
// 取消禁用
|
||||
const handleCancelDisable = useCallback(() => {
|
||||
setDisableState(null);
|
||||
}, []);
|
||||
|
||||
// 关闭不支持提示
|
||||
const handleCloseUnsupported = useCallback(() => {
|
||||
setUnsupportedState(null);
|
||||
}, []);
|
||||
|
||||
// 检查模型是否已禁用
|
||||
const isModelDisabled = useCallback((source: string, model: string): boolean => {
|
||||
// 首先检查全局状态中是否已禁用
|
||||
if (isDisabled(source, model)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果提供了 providerModels,检查配置中是否已移除
|
||||
if (providerModels) {
|
||||
if (!source || !model) return false;
|
||||
|
||||
// 首先尝试完全匹配
|
||||
if (providerModels[source]) {
|
||||
return !providerModels[source].has(model);
|
||||
}
|
||||
|
||||
// 然后尝试前缀匹配
|
||||
const entries = Object.entries(providerModels);
|
||||
for (const [key, modelSet] of entries) {
|
||||
if (source.startsWith(key) || key.startsWith(source)) {
|
||||
return !modelSet.has(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isDisabled, providerModels]);
|
||||
|
||||
return {
|
||||
disableState,
|
||||
unsupportedState,
|
||||
disabling,
|
||||
handleDisableClick,
|
||||
handleConfirmDisable,
|
||||
handleCancelDisable,
|
||||
handleCloseUnsupported,
|
||||
isModelDisabled,
|
||||
};
|
||||
}
|
||||
@@ -95,8 +95,7 @@
|
||||
"usage_stats": "Usage Statistics",
|
||||
"config_management": "Config Management",
|
||||
"logs": "Logs Viewer",
|
||||
"system_info": "Management Center Info",
|
||||
"monitor": "Monitor Center"
|
||||
"system_info": "Management Center Info"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -294,12 +293,12 @@
|
||||
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "Model alias (optional)",
|
||||
"openai_models_add_btn": "Add Model",
|
||||
"openai_models_fetch_button": "Fetch via /v1/models",
|
||||
"openai_models_fetch_title": "Pick Models from /v1/models",
|
||||
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_button": "Fetch via /models",
|
||||
"openai_models_fetch_title": "Pick Models from /models",
|
||||
"openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
|
||||
"openai_models_fetch_url_label": "Request URL",
|
||||
"openai_models_fetch_refresh": "Refresh",
|
||||
"openai_models_fetch_loading": "Fetching models from /v1/models...",
|
||||
"openai_models_fetch_loading": "Fetching models from /models...",
|
||||
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
|
||||
"openai_models_fetch_error": "Failed to fetch models",
|
||||
"openai_models_fetch_back": "Back to edit",
|
||||
@@ -325,10 +324,7 @@
|
||||
"openai_test_success": "Test succeeded. The model responded.",
|
||||
"openai_test_failed": "Test failed",
|
||||
"openai_test_select_placeholder": "Choose from current models",
|
||||
"openai_test_select_empty": "No models configured. Add models first",
|
||||
"search_placeholder": "Search configs (keys, URLs, models...)",
|
||||
"search_empty_title": "No matching configs",
|
||||
"search_empty_desc": "Try a different keyword or clear the search box"
|
||||
"openai_test_select_empty": "No models configured. Add models first"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "Auth Files Management",
|
||||
@@ -399,7 +395,19 @@
|
||||
"models_unsupported": "This feature is not supported in the current version",
|
||||
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
|
||||
"models_excluded_badge": "Excluded",
|
||||
"models_excluded_hint": "This model is excluded by OAuth"
|
||||
"models_excluded_hint": "This model is excluded by OAuth",
|
||||
"status_toggle_label": "Enabled",
|
||||
"status_enabled_success": "\"{{name}}\" enabled",
|
||||
"status_disabled_success": "\"{{name}}\" disabled",
|
||||
"prefix_proxy_button": "Edit prefix/proxy_url",
|
||||
"prefix_proxy_loading": "Loading credential...",
|
||||
"prefix_proxy_source_label": "Credential JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
|
||||
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity Quota",
|
||||
@@ -511,6 +519,12 @@
|
||||
"provider_label": "Provider",
|
||||
"provider_placeholder": "e.g. gemini-cli / vertex",
|
||||
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
|
||||
"model_source_label": "Auth file model source",
|
||||
"model_source_placeholder": "Select an auth file (for model suggestions)",
|
||||
"model_source_hint": "Pick an auth file to enable model suggestions for “Source model name”. You can still type custom values.",
|
||||
"model_source_loading": "Loading models...",
|
||||
"model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
|
||||
"model_source_loaded": "{{count}} models loaded. Use the dropdown in “Source model name”, or type custom values.",
|
||||
"mappings_label": "Model mappings",
|
||||
"mapping_name_placeholder": "Source model name",
|
||||
"mapping_alias_placeholder": "Alias (required)",
|
||||
@@ -791,9 +805,9 @@
|
||||
"not_loaded": "Not Loaded",
|
||||
"seconds_ago": "seconds ago",
|
||||
"models_title": "Available Models",
|
||||
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.",
|
||||
"models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
|
||||
"models_loading": "Loading available models...",
|
||||
"models_empty": "No models returned by /v1/models",
|
||||
"models_empty": "No models returned by /models",
|
||||
"models_error": "Failed to load model list",
|
||||
"models_count": "{{count}} available models",
|
||||
"version_check_title": "Update Check",
|
||||
@@ -910,170 +924,5 @@
|
||||
"build_date": "Build Time",
|
||||
"version": "Management UI Version",
|
||||
"author": "Author"
|
||||
},
|
||||
"monitor": {
|
||||
"title": "Monitor Center",
|
||||
"time_range": "Time Range",
|
||||
"today": "Today",
|
||||
"last_n_days": "Last {{n}} Days",
|
||||
"api_filter": "API Query",
|
||||
"api_filter_placeholder": "Query API data",
|
||||
"apply": "Apply",
|
||||
"no_data": "No data available",
|
||||
"requests": "Requests",
|
||||
"kpi": {
|
||||
"requests": "Requests",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"rate": "Success Rate",
|
||||
"tokens": "Tokens",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"reasoning": "Reasoning",
|
||||
"cached": "Cached",
|
||||
"avg_tpm": "Avg TPM",
|
||||
"avg_rpm": "Avg RPM",
|
||||
"avg_rpd": "Avg RPD",
|
||||
"tokens_per_minute": "Tokens per minute",
|
||||
"requests_per_minute": "Requests per minute",
|
||||
"requests_per_day": "Requests per day"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "Model Usage Distribution",
|
||||
"by_requests": "By Requests",
|
||||
"by_tokens": "By Tokens",
|
||||
"requests": "Requests",
|
||||
"tokens": "Tokens",
|
||||
"request_share": "Request Share",
|
||||
"token_share": "Token Share"
|
||||
},
|
||||
"trend": {
|
||||
"title": "Daily Usage Trend",
|
||||
"subtitle": "Requests and Token usage trend",
|
||||
"requests": "Requests",
|
||||
"input_tokens": "Input Tokens",
|
||||
"output_tokens": "Output Tokens",
|
||||
"reasoning_tokens": "Reasoning Tokens",
|
||||
"cached_tokens": "Cached Tokens"
|
||||
},
|
||||
"hourly": {
|
||||
"last_6h": "Last 6 Hours",
|
||||
"last_12h": "Last 12 Hours",
|
||||
"last_24h": "Last 24 Hours",
|
||||
"all": "All",
|
||||
"requests": "Requests",
|
||||
"success_rate": "Success Rate"
|
||||
},
|
||||
"hourly_model": {
|
||||
"title": "Hourly Model Request Distribution",
|
||||
"models": "Models"
|
||||
},
|
||||
"hourly_token": {
|
||||
"title": "Hourly Token Usage",
|
||||
"subtitle": "By Hour",
|
||||
"total": "Total Tokens",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"reasoning": "Reasoning",
|
||||
"cached": "Cached"
|
||||
},
|
||||
"channel": {
|
||||
"title": "Channel Statistics",
|
||||
"subtitle": "Grouped by source channel",
|
||||
"click_hint": "Click row to expand model details",
|
||||
"all_channels": "All Channels",
|
||||
"all_models": "All Models",
|
||||
"all_status": "All Status",
|
||||
"only_success": "Success Only",
|
||||
"only_failed": "Failed Only",
|
||||
"header_name": "Channel",
|
||||
"header_count": "Requests",
|
||||
"header_rate": "Success Rate",
|
||||
"header_recent": "Recent Status",
|
||||
"header_time": "Last Request",
|
||||
"model_details": "Model Details",
|
||||
"model": "Model",
|
||||
"success": "Success",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
"last_n_days": "{{n}} Days",
|
||||
"custom": "Custom",
|
||||
"to": "to",
|
||||
"apply": "Apply"
|
||||
},
|
||||
"failure": {
|
||||
"title": "Failure Analysis",
|
||||
"subtitle": "Locate issues by source channel",
|
||||
"click_hint": "Click row to expand details",
|
||||
"no_failures": "No failure data",
|
||||
"header_name": "Channel",
|
||||
"header_count": "Failures",
|
||||
"header_time": "Last Failure",
|
||||
"header_models": "Top Failed Models",
|
||||
"all_failed_models": "All Failed Models"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Request Logs",
|
||||
"total_count": "{{count}} records",
|
||||
"sort_hint": "Auto sorted by time desc",
|
||||
"scroll_hint": "Scroll to browse all data",
|
||||
"virtual_scroll_info": "Showing {{visible}} rows, {{total}} records total",
|
||||
"all_apis": "All APIs",
|
||||
"all_models": "All Models",
|
||||
"all_sources": "All Sources",
|
||||
"all_status": "All Status",
|
||||
"all_provider_types": "All Providers",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"last_update": "Last Update",
|
||||
"manual_refresh": "Manual Refresh",
|
||||
"refresh_5s": "5s Refresh",
|
||||
"refresh_10s": "10s Refresh",
|
||||
"refresh_15s": "15s Refresh",
|
||||
"refresh_30s": "30s Refresh",
|
||||
"refresh_60s": "60s Refresh",
|
||||
"refresh_in_seconds": "Refresh in {{seconds}}s",
|
||||
"refreshing": "Refreshing...",
|
||||
"header_auth": "Auth Index",
|
||||
"header_api": "API",
|
||||
"header_request_type": "Type",
|
||||
"header_model": "Model",
|
||||
"header_source": "Source",
|
||||
"header_status": "Status",
|
||||
"header_recent": "Recent Status",
|
||||
"header_rate": "Success Rate",
|
||||
"header_count": "Requests",
|
||||
"header_input": "Input",
|
||||
"header_output": "Output",
|
||||
"header_total": "Total Tokens",
|
||||
"header_time": "Time",
|
||||
"header_actions": "Actions",
|
||||
"showing": "Showing {{start}}-{{end}} of {{total}}",
|
||||
"page_info": "Page {{current}}/{{total}}",
|
||||
"first_page": "First",
|
||||
"prev_page": "Prev",
|
||||
"next_page": "Next",
|
||||
"last_page": "Last",
|
||||
"disable": "Disable",
|
||||
"disable_model": "Disable this model",
|
||||
"disabled": "Disabled",
|
||||
"removed": "Removed",
|
||||
"disabling": "Disabling...",
|
||||
"disable_confirm_title": "Confirm Disable Model",
|
||||
"disable_error": "Disable failed",
|
||||
"disable_error_no_provider": "Cannot identify provider",
|
||||
"disable_error_provider_not_found": "Provider config not found: {{provider}}",
|
||||
"disable_not_supported": "{{provider}} provider does not support disable operation",
|
||||
"disable_unsupported_title": "Auto-disable Not Supported",
|
||||
"disable_unsupported_desc": "{{providerType}} type providers do not support auto-disable feature.",
|
||||
"disable_unsupported_guide_title": "Manual Operation Guide",
|
||||
"disable_unsupported_guide_step1": "1. Go to the \"AI Providers\" page",
|
||||
"disable_unsupported_guide_step2": "2. Find the corresponding {{providerType}} configuration",
|
||||
"disable_unsupported_guide_step3": "3. Edit the config and remove model \"{{model}}\"",
|
||||
"disable_unsupported_guide_step4": "4. Save the configuration to apply changes",
|
||||
"disable_unsupported_close": "Got it"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +95,7 @@
|
||||
"usage_stats": "使用统计",
|
||||
"config_management": "配置管理",
|
||||
"logs": "日志查看",
|
||||
"system_info": "中心信息",
|
||||
"monitor": "监控中心"
|
||||
"system_info": "中心信息"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
@@ -294,12 +293,12 @@
|
||||
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
|
||||
"openai_model_alias_placeholder": "模型别名 (可选)",
|
||||
"openai_models_add_btn": "添加模型",
|
||||
"openai_models_fetch_button": "从 /v1/models 获取",
|
||||
"openai_models_fetch_title": "从 /v1/models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_button": "从 /models 获取",
|
||||
"openai_models_fetch_title": "从 /models 选择模型",
|
||||
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API Key(Bearer)与自定义请求头。",
|
||||
"openai_models_fetch_url_label": "请求地址",
|
||||
"openai_models_fetch_refresh": "重新获取",
|
||||
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
|
||||
"openai_models_fetch_loading": "正在从 /models 获取模型列表...",
|
||||
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
|
||||
"openai_models_fetch_error": "获取模型失败",
|
||||
"openai_models_fetch_back": "返回编辑",
|
||||
@@ -325,10 +324,7 @@
|
||||
"openai_test_success": "测试成功,模型可用。",
|
||||
"openai_test_failed": "测试失败",
|
||||
"openai_test_select_placeholder": "从当前模型列表选择",
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型",
|
||||
"search_placeholder": "搜索配置(密钥、地址、模型等)",
|
||||
"search_empty_title": "没有匹配的配置",
|
||||
"search_empty_desc": "请尝试更换关键字或清空搜索框"
|
||||
"openai_test_select_empty": "当前未配置模型,请先添加模型"
|
||||
},
|
||||
"auth_files": {
|
||||
"title": "认证文件管理",
|
||||
@@ -399,7 +395,19 @@
|
||||
"models_unsupported": "当前版本不支持此功能",
|
||||
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
|
||||
"models_excluded_badge": "已排除",
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除"
|
||||
"models_excluded_hint": "此模型已被 OAuth 排除",
|
||||
"status_toggle_label": "启用",
|
||||
"status_enabled_success": "已启用 \"{{name}}\"",
|
||||
"status_disabled_success": "已停用 \"{{name}}\"",
|
||||
"prefix_proxy_button": "配置 prefix/proxy_url",
|
||||
"prefix_proxy_loading": "正在加载凭证文件...",
|
||||
"prefix_proxy_source_label": "凭证 JSON",
|
||||
"prefix_label": "prefix",
|
||||
"proxy_url_label": "proxy_url",
|
||||
"prefix_placeholder": "",
|
||||
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
|
||||
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
|
||||
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
|
||||
},
|
||||
"antigravity_quota": {
|
||||
"title": "Antigravity 额度",
|
||||
@@ -511,6 +519,12 @@
|
||||
"provider_label": "提供商",
|
||||
"provider_placeholder": "例如 gemini-cli / vertex",
|
||||
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
|
||||
"model_source_label": "模型来源认证文件",
|
||||
"model_source_placeholder": "选择认证文件(用于原模型下拉建议)",
|
||||
"model_source_hint": "选择一个认证文件后,“原模型名称”支持下拉选择;也可手动输入自定义模型。",
|
||||
"model_source_loading": "正在加载模型列表...",
|
||||
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
|
||||
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。",
|
||||
"mappings_label": "模型映射",
|
||||
"mapping_name_placeholder": "原模型名称",
|
||||
"mapping_alias_placeholder": "别名 (必填)",
|
||||
@@ -791,9 +805,9 @@
|
||||
"not_loaded": "未加载",
|
||||
"seconds_ago": "秒前",
|
||||
"models_title": "可用模型列表",
|
||||
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
|
||||
"models_loading": "正在加载可用模型...",
|
||||
"models_empty": "未从 /v1/models 获取到模型数据",
|
||||
"models_empty": "未从 /models 获取到模型数据",
|
||||
"models_error": "获取模型列表失败",
|
||||
"models_count": "可用模型 {{count}} 个",
|
||||
"version_check_title": "版本检查",
|
||||
@@ -910,170 +924,5 @@
|
||||
"build_date": "构建时间",
|
||||
"version": "管理中心版本",
|
||||
"author": "作者"
|
||||
},
|
||||
"monitor": {
|
||||
"title": "监控中心",
|
||||
"time_range": "时间范围",
|
||||
"today": "今天",
|
||||
"last_n_days": "最近 {{n}} 天",
|
||||
"api_filter": "API 查询",
|
||||
"api_filter_placeholder": "查询对应 API 数据",
|
||||
"apply": "查看",
|
||||
"no_data": "暂无数据",
|
||||
"requests": "请求",
|
||||
"kpi": {
|
||||
"requests": "请求数",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"rate": "成功率",
|
||||
"tokens": "Tokens",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"reasoning": "思考",
|
||||
"cached": "缓存",
|
||||
"avg_tpm": "平均 TPM",
|
||||
"avg_rpm": "平均 RPM",
|
||||
"avg_rpd": "日均 RPD",
|
||||
"tokens_per_minute": "每分钟 Token",
|
||||
"requests_per_minute": "每分钟请求",
|
||||
"requests_per_day": "每日请求数"
|
||||
},
|
||||
"distribution": {
|
||||
"title": "模型用量分布",
|
||||
"by_requests": "按请求数",
|
||||
"by_tokens": "按 Token 数",
|
||||
"requests": "请求",
|
||||
"tokens": "Token",
|
||||
"request_share": "请求占比",
|
||||
"token_share": "Token 占比"
|
||||
},
|
||||
"trend": {
|
||||
"title": "每日用量趋势",
|
||||
"subtitle": "请求数与 Token 用量趋势",
|
||||
"requests": "请求数",
|
||||
"input_tokens": "输入 Token",
|
||||
"output_tokens": "输出 Token",
|
||||
"reasoning_tokens": "思考 Token",
|
||||
"cached_tokens": "缓存 Token"
|
||||
},
|
||||
"hourly": {
|
||||
"last_6h": "最近 6 小时",
|
||||
"last_12h": "最近 12 小时",
|
||||
"last_24h": "最近 24 小时",
|
||||
"all": "全部",
|
||||
"requests": "请求数",
|
||||
"success_rate": "成功率"
|
||||
},
|
||||
"hourly_model": {
|
||||
"title": "每小时模型请求分布",
|
||||
"models": "模型"
|
||||
},
|
||||
"hourly_token": {
|
||||
"title": "每小时 Token 用量",
|
||||
"subtitle": "按小时显示",
|
||||
"total": "总 Token",
|
||||
"input": "输入",
|
||||
"output": "输出",
|
||||
"reasoning": "思考",
|
||||
"cached": "缓存"
|
||||
},
|
||||
"channel": {
|
||||
"title": "渠道统计",
|
||||
"subtitle": "按来源渠道分类",
|
||||
"click_hint": "单击行展开模型详情",
|
||||
"all_channels": "全部渠道",
|
||||
"all_models": "全部模型",
|
||||
"all_status": "全部状态",
|
||||
"only_success": "仅成功",
|
||||
"only_failed": "仅失败",
|
||||
"header_name": "渠道",
|
||||
"header_count": "请求数",
|
||||
"header_rate": "成功率",
|
||||
"header_recent": "最近请求状态",
|
||||
"header_time": "最近请求时间",
|
||||
"model_details": "模型详情",
|
||||
"model": "模型",
|
||||
"success": "成功",
|
||||
"failed": "失败"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
"last_n_days": "{{n}} 天",
|
||||
"custom": "自定义",
|
||||
"to": "至",
|
||||
"apply": "应用"
|
||||
},
|
||||
"failure": {
|
||||
"title": "失败来源分析",
|
||||
"subtitle": "从来源渠道定位异常",
|
||||
"click_hint": "单击行展开详情",
|
||||
"no_failures": "暂无失败数据",
|
||||
"header_name": "渠道",
|
||||
"header_count": "失败数",
|
||||
"header_time": "最近失败",
|
||||
"header_models": "主要失败模型",
|
||||
"all_failed_models": "所有失败模型"
|
||||
},
|
||||
"logs": {
|
||||
"title": "请求日志",
|
||||
"total_count": "共 {{count}} 条",
|
||||
"sort_hint": "自动按时间倒序",
|
||||
"scroll_hint": "滚动浏览全部数据",
|
||||
"virtual_scroll_info": "当前显示 {{visible}} 行,共 {{total}} 条记录",
|
||||
"all_apis": "全部请求 API",
|
||||
"all_models": "全部请求模型",
|
||||
"all_sources": "全部请求渠道",
|
||||
"all_status": "全部请求状态",
|
||||
"all_provider_types": "全部请求类型",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"last_update": "最后更新",
|
||||
"manual_refresh": "手动刷新",
|
||||
"refresh_5s": "5秒刷新",
|
||||
"refresh_10s": "10秒刷新",
|
||||
"refresh_15s": "15秒刷新",
|
||||
"refresh_30s": "30秒刷新",
|
||||
"refresh_60s": "60秒刷新",
|
||||
"refresh_in_seconds": "{{seconds}}秒后刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"header_auth": "认证索引",
|
||||
"header_api": "请求 API",
|
||||
"header_request_type": "请求类型",
|
||||
"header_model": "请求模型",
|
||||
"header_source": "请求渠道",
|
||||
"header_status": "请求状态",
|
||||
"header_recent": "最近请求状态",
|
||||
"header_rate": "成功率",
|
||||
"header_count": "请求数",
|
||||
"header_input": "输入",
|
||||
"header_output": "输出",
|
||||
"header_total": "总 Token",
|
||||
"header_time": "时间",
|
||||
"header_actions": "操作",
|
||||
"showing": "显示 {{start}}-{{end}} 条,共 {{total}} 条",
|
||||
"page_info": "第 {{current}}/{{total}} 页",
|
||||
"first_page": "首页",
|
||||
"prev_page": "上一页",
|
||||
"next_page": "下一页",
|
||||
"last_page": "末页",
|
||||
"disable": "禁用",
|
||||
"disable_model": "禁用此模型",
|
||||
"disabled": "已禁用",
|
||||
"removed": "已移除",
|
||||
"disabling": "禁用中...",
|
||||
"disable_confirm_title": "确认禁用模型",
|
||||
"disable_error": "禁用失败",
|
||||
"disable_error_no_provider": "无法识别渠道",
|
||||
"disable_error_provider_not_found": "未找到渠道配置:{{provider}}",
|
||||
"disable_not_supported": "{{provider}} 渠道不支持禁用操作",
|
||||
"disable_unsupported_title": "不支持自动禁用",
|
||||
"disable_unsupported_desc": "{{providerType}} 类型的渠道暂不支持自动禁用功能。",
|
||||
"disable_unsupported_guide_title": "手动操作指南",
|
||||
"disable_unsupported_guide_step1": "1. 前往「AI 提供商」页面",
|
||||
"disable_unsupported_guide_step2": "2. 找到对应的 {{providerType}} 配置",
|
||||
"disable_unsupported_guide_step3": "3. 编辑配置,移除模型「{{model}}」",
|
||||
"disable_unsupported_guide_step4": "4. 保存配置即可生效",
|
||||
"disable_unsupported_close": "我知道了"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,53 +20,7 @@
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
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);
|
||||
margin: 0 0 $spacing-xl 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { entriesToModels } from '@/components/ui/ModelInputList';
|
||||
import {
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
@@ -29,7 +28,7 @@ import styles from './AiProvidersPage.module.scss';
|
||||
|
||||
export function AiProvidersPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
@@ -51,132 +50,10 @@ 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) => {
|
||||
@@ -303,18 +180,25 @@ export function AiProvidersPage() {
|
||||
const deleteGemini = async (index: number) => {
|
||||
const entry = geminiKeys[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
setGeminiKeys(next);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(t('notification.gemini_key_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
|
||||
message: t('ai_providers.gemini_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteGeminiKey(entry.apiKey);
|
||||
const next = geminiKeys.filter((_, idx) => idx !== index);
|
||||
setGeminiKeys(next);
|
||||
updateConfigValue('gemini-api-key', next);
|
||||
clearCache('gemini-api-key');
|
||||
showNotification(t('notification.gemini_key_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const setConfigEnabled = async (
|
||||
@@ -475,27 +359,34 @@ export function AiProvidersPage() {
|
||||
const source = type === 'codex' ? codexConfigs : claudeConfigs;
|
||||
const entry = source[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return;
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
clearCache('claude-api-key');
|
||||
showNotification(t('notification.claude_config_deleted'), 'success');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
showConfirmation({
|
||||
title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
|
||||
message: t(`ai_providers.${type}_delete_confirm`),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (type === 'codex') {
|
||||
await providersApi.deleteCodexConfig(entry.apiKey);
|
||||
const next = codexConfigs.filter((_, idx) => idx !== index);
|
||||
setCodexConfigs(next);
|
||||
updateConfigValue('codex-api-key', next);
|
||||
clearCache('codex-api-key');
|
||||
showNotification(t('notification.codex_config_deleted'), 'success');
|
||||
} else {
|
||||
await providersApi.deleteClaudeConfig(entry.apiKey);
|
||||
const next = claudeConfigs.filter((_, idx) => idx !== index);
|
||||
setClaudeConfigs(next);
|
||||
updateConfigValue('claude-api-key', next);
|
||||
clearCache('claude-api-key');
|
||||
showNotification(t('notification.claude_config_deleted'), 'success');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
|
||||
@@ -550,18 +441,25 @@ export function AiProvidersPage() {
|
||||
const deleteVertex = async (index: number) => {
|
||||
const entry = vertexConfigs[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
updateConfigValue('vertex-api-key', next);
|
||||
clearCache('vertex-api-key');
|
||||
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
|
||||
message: t('ai_providers.vertex_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteVertexConfig(entry.apiKey);
|
||||
const next = vertexConfigs.filter((_, idx) => idx !== index);
|
||||
setVertexConfigs(next);
|
||||
updateConfigValue('vertex-api-key', next);
|
||||
clearCache('vertex-api-key');
|
||||
showNotification(t('notification.vertex_config_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
|
||||
@@ -608,18 +506,25 @@ export function AiProvidersPage() {
|
||||
const deleteOpenai = async (index: number) => {
|
||||
const entry = openaiProviders[index];
|
||||
if (!entry) return;
|
||||
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return;
|
||||
try {
|
||||
await providersApi.deleteOpenAIProvider(entry.name);
|
||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||
setOpenaiProviders(next);
|
||||
updateConfigValue('openai-compatibility', next);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(t('notification.openai_provider_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
|
||||
message: t('ai_providers.openai_delete_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await providersApi.deleteOpenAIProvider(entry.name);
|
||||
const next = openaiProviders.filter((_, idx) => idx !== index);
|
||||
setOpenaiProviders(next);
|
||||
updateConfigValue('openai-compatibility', next);
|
||||
clearCache('openai-compatibility');
|
||||
showNotification(t('notification.openai_provider_deleted'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
|
||||
@@ -630,171 +535,112 @@ export function AiProvidersPage() {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<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>
|
||||
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
|
||||
<div className={styles.content}>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
|
||||
{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>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
<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)}
|
||||
/>
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
<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)}
|
||||
/>
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
<AmpcodeSection
|
||||
config={config?.ampcode}
|
||||
loading={loading}
|
||||
disableControls={disableControls}
|
||||
isSaving={saving}
|
||||
isSwitching={isSwitching}
|
||||
isBusy={ampcodeBusy}
|
||||
isModalOpen={modal?.type === 'ampcode'}
|
||||
onOpen={openAmpcodeModal}
|
||||
onCloseModal={closeModal}
|
||||
onBusyChange={setAmpcodeBusy}
|
||||
/>
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './ApiKeysPage.module.scss';
|
||||
|
||||
export function ApiKeysPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
|
||||
const config = useConfigStore((state) => state.config);
|
||||
@@ -29,7 +29,6 @@ export function ApiKeysPage() {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingIndex, setDeletingIndex] = useState<number | null>(null);
|
||||
|
||||
const disableControls = useMemo(() => connectionStatus !== 'connected', [connectionStatus]);
|
||||
|
||||
@@ -115,21 +114,42 @@ export function ApiKeysPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (index: number) => {
|
||||
if (!window.confirm(t('api_keys.delete_confirm'))) return;
|
||||
setDeletingIndex(index);
|
||||
try {
|
||||
await apiKeysApi.delete(index);
|
||||
const nextKeys = apiKeys.filter((_, idx) => idx !== index);
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
showNotification(t('notification.api_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
} finally {
|
||||
setDeletingIndex(null);
|
||||
const handleDelete = (index: number) => {
|
||||
const apiKeyToDelete = apiKeys[index];
|
||||
if (!apiKeyToDelete) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmation({
|
||||
title: t('common.delete'),
|
||||
message: t('api_keys.delete_confirm'),
|
||||
variant: 'danger',
|
||||
onConfirm: async () => {
|
||||
const latestKeys = useConfigStore.getState().config?.apiKeys;
|
||||
const currentKeys = Array.isArray(latestKeys) ? latestKeys : [];
|
||||
const deleteIndex =
|
||||
currentKeys[index] === apiKeyToDelete
|
||||
? index
|
||||
: currentKeys.findIndex((key) => key === apiKeyToDelete);
|
||||
|
||||
if (deleteIndex < 0) {
|
||||
showNotification(t('notification.delete_failed'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiKeysApi.delete(deleteIndex);
|
||||
const nextKeys = currentKeys.filter((_, idx) => idx !== deleteIndex);
|
||||
setApiKeys(nextKeys);
|
||||
updateConfigValue('api-keys', nextKeys);
|
||||
clearCache('api-keys');
|
||||
showNotification(t('notification.api_key_deleted'), 'success');
|
||||
} catch (err: any) {
|
||||
showNotification(`${t('notification.delete_failed')}: ${err?.message || ''}`, 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const actionButtons = (
|
||||
@@ -181,8 +201,7 @@ export function ApiKeysPage() {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
disabled={disableControls || deletingIndex === index}
|
||||
loading={deletingIndex === index}
|
||||
disabled={disableControls}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -277,27 +277,15 @@
|
||||
}
|
||||
|
||||
.antigravityCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(224, 247, 250, 0.12),
|
||||
rgba(224, 247, 250, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
|
||||
}
|
||||
|
||||
.codexCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 243, 224, 0.18),
|
||||
rgba(255, 243, 224, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
|
||||
}
|
||||
|
||||
.geminiCliCard {
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(231, 239, 255, 0.2),
|
||||
rgba(231, 239, 255, 0)
|
||||
);
|
||||
background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
|
||||
}
|
||||
|
||||
.quotaSection {
|
||||
@@ -446,7 +434,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
|
||||
transition:
|
||||
transform $transition-fast,
|
||||
box-shadow $transition-fast,
|
||||
border-color $transition-fast;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
@@ -546,7 +537,9 @@
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
min-width: 6px;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scaleY(1.5);
|
||||
@@ -597,14 +590,90 @@
|
||||
background: var(--failure-badge-bg, #fee2e2);
|
||||
}
|
||||
|
||||
.prefixProxyEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.prefixProxyLoading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $spacing-sm;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
.prefixProxyError {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-radius: $radius-md;
|
||||
border: 1px solid var(--danger-color);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prefixProxyJsonWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prefixProxyLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prefixProxyTextarea {
|
||||
width: 100%;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: $radius-md;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.prefixProxyFields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $spacing-sm;
|
||||
|
||||
:global(.form-group) {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
gap: $spacing-xs;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.statusToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
|
||||
.iconButton:global(.btn.btn-sm) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
|
||||
|
||||
export function LogsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
||||
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
|
||||
|
||||
@@ -478,19 +478,26 @@ export function LogsPage() {
|
||||
useHeaderRefresh(() => loadLogs(false));
|
||||
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestTimestampRef.current = 0;
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
showConfirmation({
|
||||
title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
|
||||
message: t('logs.clear_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await logsApi.clearLogs();
|
||||
setLogState({ buffer: [], visibleFrom: 0 });
|
||||
latestTimestampRef.current = 0;
|
||||
showNotification(t('logs.clear_success'), 'success');
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
showNotification(
|
||||
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadLogs = () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,379 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
|
||||
|
||||
export function SystemPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showNotification } = useNotificationStore();
|
||||
const { showNotification, showConfirmation } = useNotificationStore();
|
||||
const auth = useAuthStore();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const fetchConfig = useConfigStore((state) => state.fetchConfig);
|
||||
@@ -106,12 +106,19 @@ export function SystemPage() {
|
||||
};
|
||||
|
||||
const handleClearLoginStorage = () => {
|
||||
if (!window.confirm(t('system_info.clear_login_confirm'))) return;
|
||||
auth.logout();
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||
showConfirmation({
|
||||
title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
|
||||
message: t('system_info.clear_login_confirm'),
|
||||
variant: 'danger',
|
||||
confirmText: t('common.confirm'),
|
||||
onConfirm: () => {
|
||||
auth.logout();
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
showNotification(t('notification.login_storage_cleared'), 'success');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,7 +188,7 @@ export function SystemPage() {
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/kongkongyo/Cli-Proxy-API-Management-Center"
|
||||
href="https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.linkCard}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { UsagePage } from '@/pages/UsagePage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { LogsPage } from '@/pages/LogsPage';
|
||||
import { SystemPage } from '@/pages/SystemPage';
|
||||
import { MonitorPage } from '@/pages/MonitorPage';
|
||||
|
||||
const mainRoutes = [
|
||||
{ path: '/', element: <DashboardPage /> },
|
||||
@@ -25,7 +24,6 @@ const mainRoutes = [
|
||||
{ path: '/config', element: <ConfigPage /> },
|
||||
{ path: '/logs', element: <LogsPage /> },
|
||||
{ path: '/system', element: <SystemPage /> },
|
||||
{ path: '/monitor', element: <MonitorPage /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,10 +6,20 @@ import { apiClient } from './client';
|
||||
import type { AuthFilesResponse } from '@/types/authFile';
|
||||
import type { OAuthModelMappingEntry } from '@/types';
|
||||
|
||||
type StatusError = { status?: number };
|
||||
type AuthFileStatusResponse = { status: string; disabled: boolean };
|
||||
|
||||
const getStatusCode = (err: unknown): number | undefined => {
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if ('status' in err) return (err as StatusError).status;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
@@ -43,9 +53,63 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => {
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const source =
|
||||
record['oauth-model-mappings'] ??
|
||||
record['oauth-model-alias'] ??
|
||||
record.items ??
|
||||
payload;
|
||||
if (!source || typeof source !== 'object') return {};
|
||||
|
||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||
|
||||
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
|
||||
const key = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key) return;
|
||||
if (!Array.isArray(mappings)) return;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized = mappings
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
|
||||
const alias = String(entry.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const fork = entry.fork === true;
|
||||
return fork ? { name, alias, fork } : { name, alias };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((entry) => {
|
||||
const mapping = entry as OAuthModelMappingEntry;
|
||||
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`;
|
||||
if (seen.has(dedupeKey)) return false;
|
||||
seen.add(dedupeKey);
|
||||
return true;
|
||||
}) as OAuthModelMappingEntry[];
|
||||
|
||||
if (normalized.length) {
|
||||
result[key] = normalized;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings';
|
||||
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
|
||||
|
||||
export const authFilesApi = {
|
||||
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
|
||||
|
||||
setStatus: (name: string, disabled: boolean) =>
|
||||
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
|
||||
|
||||
upload: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
@@ -81,34 +145,63 @@ export const authFilesApi = {
|
||||
|
||||
// OAuth 模型映射
|
||||
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
|
||||
const data = await apiClient.get('/oauth-model-alias');
|
||||
const payload = (data && (data['oauth-model-alias'] ?? data.items ?? data)) as any;
|
||||
if (!payload || typeof payload !== 'object') return {};
|
||||
const result: Record<string, OAuthModelMappingEntry[]> = {};
|
||||
Object.entries(payload).forEach(([channel, mappings]) => {
|
||||
if (!Array.isArray(mappings)) return;
|
||||
const normalized = mappings
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
|
||||
const alias = String(item.alias ?? '').trim();
|
||||
if (!name || !alias) return null;
|
||||
const fork = item.fork === true;
|
||||
return fork ? { name, alias, fork } : { name, alias };
|
||||
})
|
||||
.filter(Boolean) as OAuthModelMappingEntry[];
|
||||
if (normalized.length) {
|
||||
result[channel] = normalized;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
try {
|
||||
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT);
|
||||
return normalizeOauthModelMappings(data);
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
|
||||
return normalizeOauthModelMappings(data);
|
||||
}
|
||||
},
|
||||
|
||||
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
|
||||
apiClient.patch('/oauth-model-alias', { channel, mappings }),
|
||||
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => {
|
||||
const normalizedChannel = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? [];
|
||||
|
||||
deleteOauthModelMappings: (channel: string) =>
|
||||
apiClient.delete(`/oauth-model-alias?channel=${encodeURIComponent(channel)}`),
|
||||
try {
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
|
||||
}
|
||||
},
|
||||
|
||||
deleteOauthModelMappings: async (channel: string) => {
|
||||
const normalizedChannel = String(channel ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const deleteViaPatch = async () => {
|
||||
try {
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await deleteViaPatch();
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = getStatusCode(err);
|
||||
if (status !== 405) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
if (getStatusCode(err) !== 404) throw err;
|
||||
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取认证凭证支持的模型
|
||||
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
|
||||
|
||||
@@ -20,7 +20,7 @@ const normalizeBaseUrl = (baseUrl: string): string => {
|
||||
const buildModelsEndpoint = (baseUrl: string): string => {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (!normalized) return '';
|
||||
return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`;
|
||||
return `${normalized}/models`;
|
||||
};
|
||||
|
||||
export const modelsApi = {
|
||||
|
||||
@@ -194,14 +194,5 @@ export const providersApi = {
|
||||
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
|
||||
|
||||
deleteOpenAIProvider: (name: string) =>
|
||||
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
|
||||
|
||||
// 通过 name 更新 OpenAI 兼容提供商(用于禁用模型)
|
||||
patchOpenAIProviderByName: (name: string, value: Partial<OpenAIProviderConfig>) => {
|
||||
const payload: Record<string, any> = {};
|
||||
if (value.models !== undefined) {
|
||||
payload.models = serializeModelAliases(value.models);
|
||||
}
|
||||
return apiClient.patch('/openai-compatibility', { name, value: payload });
|
||||
}
|
||||
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
|
||||
};
|
||||
|
||||
@@ -9,4 +9,3 @@ export { useAuthStore } from './useAuthStore';
|
||||
export { useConfigStore } from './useConfigStore';
|
||||
export { useModelsStore } from './useModelsStore';
|
||||
export { useQuotaStore } from './useQuotaStore';
|
||||
export { useDisabledModelsStore } from './useDisabledModelsStore';
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 禁用模型状态管理
|
||||
* 全局管理已禁用的模型,确保所有组件状态同步
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface DisabledModelsState {
|
||||
/** 已禁用的模型集合,格式:`${source}|||${model}` */
|
||||
disabledModels: Set<string>;
|
||||
/** 添加禁用模型 */
|
||||
addDisabledModel: (source: string, model: string) => void;
|
||||
/** 移除禁用模型(恢复) */
|
||||
removeDisabledModel: (source: string, model: string) => void;
|
||||
/** 检查模型是否已禁用 */
|
||||
isDisabled: (source: string, model: string) => boolean;
|
||||
/** 清空所有禁用状态 */
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const useDisabledModelsStore = create<DisabledModelsState>()((set, get) => ({
|
||||
disabledModels: new Set<string>(),
|
||||
|
||||
addDisabledModel: (source, model) => {
|
||||
const key = `${source}|||${model}`;
|
||||
set((state) => {
|
||||
const newSet = new Set(state.disabledModels);
|
||||
newSet.add(key);
|
||||
return { disabledModels: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
removeDisabledModel: (source, model) => {
|
||||
const key = `${source}|||${model}`;
|
||||
set((state) => {
|
||||
const newSet = new Set(state.disabledModels);
|
||||
newSet.delete(key);
|
||||
return { disabledModels: newSet };
|
||||
});
|
||||
},
|
||||
|
||||
isDisabled: (source, model) => {
|
||||
const key = `${source}|||${model}`;
|
||||
return get().disabledModels.has(key);
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set({ disabledModels: new Set() });
|
||||
},
|
||||
}));
|
||||
@@ -8,15 +8,38 @@ import type { Notification, NotificationType } from '@/types';
|
||||
import { generateId } from '@/utils/helpers';
|
||||
import { NOTIFICATION_DURATION_MS } from '@/utils/constants';
|
||||
|
||||
interface ConfirmationOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'primary' | 'secondary';
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
interface NotificationState {
|
||||
notifications: Notification[];
|
||||
confirmation: {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
options: ConfirmationOptions | null;
|
||||
};
|
||||
showNotification: (message: string, type?: NotificationType, duration?: number) => void;
|
||||
removeNotification: (id: string) => void;
|
||||
clearAll: () => void;
|
||||
showConfirmation: (options: ConfirmationOptions) => void;
|
||||
hideConfirmation: () => void;
|
||||
setConfirmationLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
notifications: [],
|
||||
confirmation: {
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
options: null
|
||||
},
|
||||
|
||||
showNotification: (message, type = 'info', duration = NOTIFICATION_DURATION_MS) => {
|
||||
const id = generateId();
|
||||
@@ -49,5 +72,34 @@ export const useNotificationStore = create<NotificationState>((set) => ({
|
||||
|
||||
clearAll: () => {
|
||||
set({ notifications: [] });
|
||||
},
|
||||
|
||||
showConfirmation: (options) => {
|
||||
set({
|
||||
confirmation: {
|
||||
isOpen: true,
|
||||
isLoading: false,
|
||||
options
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
hideConfirmation: () => {
|
||||
set((state) => ({
|
||||
confirmation: {
|
||||
...state.confirmation,
|
||||
isOpen: false,
|
||||
options: null // Cleanup
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
setConfirmationLoading: (loading) => {
|
||||
set((state) => ({
|
||||
confirmation: {
|
||||
...state.confirmation,
|
||||
isLoading: loading
|
||||
}
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -116,42 +116,14 @@ textarea {
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-md;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +453,18 @@ textarea {
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
|
||||
export interface GeminiCliQuotaGroupDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
preferredModelId?: string;
|
||||
modelIds: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
* 隐藏 API Key 中间部分,仅保留前后两位
|
||||
*/
|
||||
export function maskApiKey(key: string): string {
|
||||
if (!key) {
|
||||
const trimmed = String(key || '').trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const visibleChars = 2;
|
||||
const start = key.slice(0, visibleChars);
|
||||
const end = key.slice(-visibleChars);
|
||||
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
|
||||
const MASKED_LENGTH = 10;
|
||||
const visibleChars = trimmed.length < 4 ? 1 : 2;
|
||||
const start = trimmed.slice(0, visibleChars);
|
||||
const end = trimmed.slice(-visibleChars);
|
||||
const maskedLength = Math.max(MASKED_LENGTH - visibleChars * 2, 1);
|
||||
const masked = '*'.repeat(maskedLength);
|
||||
|
||||
return `${start}${masked}${end}`;
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/**
|
||||
* 监控中心公共工具函数
|
||||
*/
|
||||
|
||||
import type { UsageData } from '@/pages/MonitorPage';
|
||||
|
||||
/**
|
||||
* 日期范围接口
|
||||
*/
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用模型状态接口
|
||||
*/
|
||||
export interface DisableState {
|
||||
source: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
step: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏 API Key
|
||||
* @param key API Key 字符串
|
||||
* @returns 脱敏后的字符串
|
||||
*/
|
||||
export function maskSecret(key: string): string {
|
||||
if (!key || key === '-' || key === 'unknown') return key || '-';
|
||||
if (key.length <= 8) {
|
||||
return `${key.slice(0, 4)}***`;
|
||||
}
|
||||
return `${key.slice(0, 4)}***${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析渠道名称(返回 provider 名称)
|
||||
* @param source 来源标识
|
||||
* @param providerMap 渠道映射表
|
||||
* @returns provider 名称或 null
|
||||
*/
|
||||
export function resolveProvider(
|
||||
source: string,
|
||||
providerMap: Record<string, string>
|
||||
): string | null {
|
||||
if (!source || source === '-' || source === 'unknown') return null;
|
||||
|
||||
// 首先尝试完全匹配
|
||||
if (providerMap[source]) {
|
||||
return providerMap[source];
|
||||
}
|
||||
|
||||
// 然后尝试前缀匹配(双向)
|
||||
const entries = Object.entries(providerMap);
|
||||
for (const [key, provider] of entries) {
|
||||
if (source.startsWith(key) || key.startsWith(source)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化渠道显示名称:渠道名 (脱敏后的api-key)
|
||||
* @param source 来源标识
|
||||
* @param providerMap 渠道映射表
|
||||
* @returns 格式化后的显示名称
|
||||
*/
|
||||
export function formatProviderDisplay(
|
||||
source: string,
|
||||
providerMap: Record<string, string>
|
||||
): string {
|
||||
if (!source || source === '-' || source === 'unknown') {
|
||||
return source || '-';
|
||||
}
|
||||
const provider = resolveProvider(source, providerMap);
|
||||
const masked = maskSecret(source);
|
||||
if (!provider) return masked;
|
||||
return `${provider} (${masked})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取渠道显示信息(分离渠道名和秘钥)
|
||||
* @param source 来源标识
|
||||
* @param providerMap 渠道映射表
|
||||
* @returns 包含渠道名和秘钥的对象
|
||||
*/
|
||||
export function getProviderDisplayParts(
|
||||
source: string,
|
||||
providerMap: Record<string, string>
|
||||
): { provider: string | null; masked: string } {
|
||||
if (!source || source === '-' || source === 'unknown') {
|
||||
return { provider: null, masked: source || '-' };
|
||||
}
|
||||
const provider = resolveProvider(source, providerMap);
|
||||
const masked = maskSecret(source);
|
||||
return { provider, masked };
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间戳为日期时间字符串
|
||||
* @param timestamp 时间戳(毫秒数或 ISO 字符串)
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatTimestamp(timestamp: number | string): string {
|
||||
if (!timestamp) return '-';
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成功率对应的样式类名
|
||||
* @param rate 成功率(0-100)
|
||||
* @param styles 样式模块对象
|
||||
* @returns 样式类名
|
||||
*/
|
||||
export function getRateClassName(
|
||||
rate: number,
|
||||
styles: Record<string, string>
|
||||
): string {
|
||||
if (rate >= 90) return styles.rateHigh || '';
|
||||
if (rate >= 70) return styles.rateMedium || '';
|
||||
return styles.rateLow || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否在配置中可用(未被移除)
|
||||
* @param source 来源标识
|
||||
* @param modelAlias 模型别名
|
||||
* @param providerModels 渠道模型映射表
|
||||
* @returns 是否可用
|
||||
*/
|
||||
export function isModelEnabled(
|
||||
source: string,
|
||||
modelAlias: string,
|
||||
providerModels: Record<string, Set<string>>
|
||||
): boolean {
|
||||
if (!source || !modelAlias) return true; // 无法判断时默认显示
|
||||
// 首先尝试完全匹配
|
||||
if (providerModels[source]) {
|
||||
return providerModels[source].has(modelAlias);
|
||||
}
|
||||
// 然后尝试前缀匹配
|
||||
const entries = Object.entries(providerModels);
|
||||
for (const [key, modelSet] of entries) {
|
||||
if (source.startsWith(key) || key.startsWith(source)) {
|
||||
return modelSet.has(modelAlias);
|
||||
}
|
||||
}
|
||||
return true; // 找不到渠道配置时默认显示
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型是否已禁用(会话中禁用或配置中已移除)
|
||||
* @param source 来源标识
|
||||
* @param model 模型名称
|
||||
* @param disabledModels 已禁用模型集合
|
||||
* @param providerModels 渠道模型映射表
|
||||
* @returns 是否已禁用
|
||||
*/
|
||||
export function isModelDisabled(
|
||||
source: string,
|
||||
model: string,
|
||||
disabledModels: Set<string>,
|
||||
providerModels: Record<string, Set<string>>
|
||||
): boolean {
|
||||
// 首先检查会话中是否已禁用
|
||||
if (disabledModels.has(`${source}|||${model}`)) {
|
||||
return true;
|
||||
}
|
||||
// 然后检查配置中是否已移除
|
||||
return !isModelEnabled(source, model, providerModels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建禁用状态对象
|
||||
* @param source 来源标识
|
||||
* @param model 模型名称
|
||||
* @param providerMap 渠道映射表
|
||||
* @returns 禁用状态对象
|
||||
*/
|
||||
export function createDisableState(
|
||||
source: string,
|
||||
model: string,
|
||||
providerMap: Record<string, string>
|
||||
): DisableState {
|
||||
const providerName = resolveProvider(source, providerMap);
|
||||
const displayName = providerName
|
||||
? `${providerName} / ${model}`
|
||||
: `${maskSecret(source)} / ${model}`;
|
||||
return { source, model, displayName, step: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间范围类型
|
||||
*/
|
||||
export type TimeRangeValue = number | 'custom';
|
||||
|
||||
/**
|
||||
* 根据时间范围过滤数据
|
||||
* @param data 原始数据
|
||||
* @param timeRange 时间范围(天数或 'custom')
|
||||
* @param customRange 自定义日期范围
|
||||
* @returns 过滤后的数据
|
||||
*/
|
||||
export function filterDataByTimeRange(
|
||||
data: UsageData | null,
|
||||
timeRange: TimeRangeValue,
|
||||
customRange?: DateRange
|
||||
): UsageData | null {
|
||||
if (!data?.apis) return null;
|
||||
|
||||
const now = new Date();
|
||||
let cutoffStart: Date;
|
||||
let cutoffEnd: Date = new Date(now.getTime());
|
||||
cutoffEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (timeRange === 'custom' && customRange) {
|
||||
cutoffStart = customRange.start;
|
||||
cutoffEnd = customRange.end;
|
||||
} else if (typeof timeRange === 'number') {
|
||||
cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
} else {
|
||||
cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
cutoffStart.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const filtered: UsageData = { apis: {} };
|
||||
|
||||
Object.entries(data.apis).forEach(([apiKey, apiData]) => {
|
||||
if (!apiData?.models) return;
|
||||
|
||||
const filteredModels: Record<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||
|
||||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||||
if (!modelData?.details || !Array.isArray(modelData.details)) return;
|
||||
|
||||
const filteredDetails = modelData.details.filter((detail) => {
|
||||
const timestamp = new Date(detail.timestamp);
|
||||
return timestamp >= cutoffStart && timestamp <= cutoffEnd;
|
||||
});
|
||||
|
||||
if (filteredDetails.length > 0) {
|
||||
filteredModels[modelName] = { details: filteredDetails };
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(filteredModels).length > 0) {
|
||||
filtered.apis[apiKey] = { models: filteredModels };
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
AntigravityQuotaInfo,
|
||||
AntigravityModelsPayload,
|
||||
GeminiCliParsedBucket,
|
||||
GeminiCliQuotaBucketState
|
||||
GeminiCliQuotaBucketState,
|
||||
} from '@/types';
|
||||
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
|
||||
import { normalizeQuotaFraction } from './parsers';
|
||||
@@ -35,7 +35,19 @@ export function buildGeminiCliQuotaBuckets(
|
||||
): GeminiCliQuotaBucketState[] {
|
||||
if (buckets.length === 0) return [];
|
||||
|
||||
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>();
|
||||
type GeminiCliQuotaBucketGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
tokenType: string | null;
|
||||
modelIds: string[];
|
||||
preferredModelId?: string;
|
||||
preferredBucket?: GeminiCliParsedBucket;
|
||||
fallbackRemainingFraction: number | null;
|
||||
fallbackRemainingAmount: number | null;
|
||||
fallbackResetTime: string | undefined;
|
||||
};
|
||||
|
||||
const grouped = new Map<string, GeminiCliQuotaBucketGroup>();
|
||||
|
||||
buckets.forEach((bucket) => {
|
||||
if (isIgnoredGeminiCliModel(bucket.modelId)) return;
|
||||
@@ -47,37 +59,55 @@ export function buildGeminiCliQuotaBuckets(
|
||||
const existing = grouped.get(mapKey);
|
||||
|
||||
if (!existing) {
|
||||
const preferredModelId = group?.preferredModelId;
|
||||
const preferredBucket =
|
||||
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined;
|
||||
grouped.set(mapKey, {
|
||||
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
|
||||
label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: [bucket.modelId]
|
||||
modelIds: [bucket.modelId],
|
||||
preferredModelId,
|
||||
preferredBucket,
|
||||
fallbackRemainingFraction: bucket.remainingFraction,
|
||||
fallbackRemainingAmount: bucket.remainingAmount,
|
||||
fallbackResetTime: bucket.resetTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.remainingFraction = minNullableNumber(
|
||||
existing.remainingFraction,
|
||||
existing.fallbackRemainingFraction = minNullableNumber(
|
||||
existing.fallbackRemainingFraction,
|
||||
bucket.remainingFraction
|
||||
);
|
||||
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount);
|
||||
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime);
|
||||
existing.fallbackRemainingAmount = minNullableNumber(
|
||||
existing.fallbackRemainingAmount,
|
||||
bucket.remainingAmount
|
||||
);
|
||||
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
|
||||
existing.modelIds.push(bucket.modelId);
|
||||
|
||||
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
|
||||
existing.preferredBucket = bucket;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(grouped.values()).map((bucket) => {
|
||||
const uniqueModelIds = Array.from(new Set(bucket.modelIds));
|
||||
const preferred = bucket.preferredBucket;
|
||||
const remainingFraction = preferred
|
||||
? preferred.remainingFraction
|
||||
: bucket.fallbackRemainingFraction;
|
||||
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
|
||||
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
remainingFraction: bucket.remainingFraction,
|
||||
remainingAmount: bucket.remainingAmount,
|
||||
resetTime: bucket.resetTime,
|
||||
remainingFraction,
|
||||
remainingAmount,
|
||||
resetTime,
|
||||
tokenType: bucket.tokenType,
|
||||
modelIds: uniqueModelIds
|
||||
modelIds: uniqueModelIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -101,7 +131,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
||||
return {
|
||||
remainingFraction,
|
||||
resetTime,
|
||||
displayName
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,7 +180,7 @@ export function buildAntigravityQuotaGroups(
|
||||
id,
|
||||
remainingFraction,
|
||||
resetTime: info.resetTime,
|
||||
displayName: info.displayName
|
||||
displayName: info.displayName,
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
||||
@@ -168,7 +198,7 @@ export function buildAntigravityQuotaGroups(
|
||||
label,
|
||||
models: quotaEntries.map((entry) => entry.id),
|
||||
remainingFraction,
|
||||
resetTime
|
||||
resetTime,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -5,64 +5,64 @@
|
||||
import type {
|
||||
AntigravityQuotaGroupDefinition,
|
||||
GeminiCliQuotaGroupDefinition,
|
||||
TypeColorSet
|
||||
TypeColorSet,
|
||||
} from '@/types';
|
||||
|
||||
// Theme colors for type badges
|
||||
export const TYPE_COLORS: Record<string, TypeColorSet> = {
|
||||
qwen: {
|
||||
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
||||
dark: { bg: '#1b5e20', text: '#81c784' }
|
||||
dark: { bg: '#1b5e20', text: '#81c784' },
|
||||
},
|
||||
gemini: {
|
||||
light: { bg: '#e3f2fd', text: '#1565c0' },
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
||||
dark: { bg: '#0d47a1', text: '#64b5f6' },
|
||||
},
|
||||
'gemini-cli': {
|
||||
light: { bg: '#e7efff', text: '#1e4fa3' },
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
||||
dark: { bg: '#1c3f73', text: '#a8c7ff' },
|
||||
},
|
||||
aistudio: {
|
||||
light: { bg: '#f0f2f5', text: '#2f343c' },
|
||||
dark: { bg: '#373c42', text: '#cfd3db' }
|
||||
dark: { bg: '#373c42', text: '#cfd3db' },
|
||||
},
|
||||
claude: {
|
||||
light: { bg: '#fce4ec', text: '#c2185b' },
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' }
|
||||
dark: { bg: '#880e4f', text: '#f48fb1' },
|
||||
},
|
||||
codex: {
|
||||
light: { bg: '#fff3e0', text: '#ef6c00' },
|
||||
dark: { bg: '#e65100', text: '#ffb74d' }
|
||||
dark: { bg: '#e65100', text: '#ffb74d' },
|
||||
},
|
||||
antigravity: {
|
||||
light: { bg: '#e0f7fa', text: '#006064' },
|
||||
dark: { bg: '#004d40', text: '#80deea' }
|
||||
dark: { bg: '#004d40', text: '#80deea' },
|
||||
},
|
||||
iflow: {
|
||||
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' }
|
||||
dark: { bg: '#4a148c', text: '#ce93d8' },
|
||||
},
|
||||
empty: {
|
||||
light: { bg: '#f5f5f5', text: '#616161' },
|
||||
dark: { bg: '#424242', text: '#bdbdbd' }
|
||||
dark: { bg: '#424242', text: '#bdbdbd' },
|
||||
},
|
||||
unknown: {
|
||||
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
||||
}
|
||||
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
|
||||
},
|
||||
};
|
||||
|
||||
// Antigravity API configuration
|
||||
export const ANTIGRAVITY_QUOTA_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
];
|
||||
|
||||
export const ANTIGRAVITY_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64'
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64',
|
||||
};
|
||||
|
||||
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
@@ -73,40 +73,40 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
||||
'claude-sonnet-4-5-thinking',
|
||||
'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-5',
|
||||
'gpt-oss-120b-medium'
|
||||
]
|
||||
'gpt-oss-120b-medium',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro',
|
||||
label: 'Gemini 3 Pro',
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low']
|
||||
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking']
|
||||
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-flash-lite',
|
||||
label: 'Gemini 2.5 Flash Lite',
|
||||
identifiers: ['gemini-2.5-flash-lite']
|
||||
identifiers: ['gemini-2.5-flash-lite'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-cu',
|
||||
label: 'Gemini 2.5 CU',
|
||||
identifiers: ['rev19-uic3-1p']
|
||||
identifiers: ['rev19-uic3-1p'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash',
|
||||
label: 'Gemini 3 Flash',
|
||||
identifiers: ['gemini-3-flash']
|
||||
identifiers: ['gemini-3-flash'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-image',
|
||||
label: 'gemini-3-pro-image',
|
||||
identifiers: ['gemini-3-pro-image'],
|
||||
labelFromModel: true
|
||||
}
|
||||
labelFromModel: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Gemini CLI API configuration
|
||||
@@ -115,30 +115,22 @@ export const GEMINI_CLI_QUOTA_URL =
|
||||
|
||||
export const GEMINI_CLI_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
|
||||
{
|
||||
id: 'gemini-2-5-flash-series',
|
||||
label: 'Gemini 2.5 Flash Series',
|
||||
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite']
|
||||
id: 'gemini-flash-series',
|
||||
label: 'Gemini Flash Series',
|
||||
preferredModelId: 'gemini-3-flash-preview',
|
||||
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2-5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
modelIds: ['gemini-2.5-pro']
|
||||
id: 'gemini-pro-series',
|
||||
label: 'Gemini Pro Series',
|
||||
preferredModelId: 'gemini-3-pro-preview',
|
||||
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
label: 'Gemini 3 Pro Preview',
|
||||
modelIds: ['gemini-3-pro-preview']
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash-preview',
|
||||
label: 'Gemini 3 Flash Preview',
|
||||
modelIds: ['gemini-3-flash-preview']
|
||||
}
|
||||
];
|
||||
|
||||
export const GEMINI_CLI_GROUP_LOOKUP = new Map(
|
||||
@@ -155,5 +147,5 @@ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
||||
export const CODEX_REQUEST_HEADERS = {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal'
|
||||
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
|
||||
};
|
||||
|
||||
@@ -73,6 +73,124 @@ const normalizeAuthIndex = (value: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const USAGE_SOURCE_PREFIX_KEY = 'k:';
|
||||
const USAGE_SOURCE_PREFIX_MASKED = 'm:';
|
||||
const USAGE_SOURCE_PREFIX_TEXT = 't:';
|
||||
|
||||
const KEY_LIKE_TOKEN_REGEX =
|
||||
/(sk-[A-Za-z0-9-_]{6,}|sk-ant-[A-Za-z0-9-_]{6,}|AIza[0-9A-Za-z-_]{8,}|AI[a-zA-Z0-9_-]{6,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/;
|
||||
const MASKED_TOKEN_HINT_REGEX = /^[^\s]{1,24}(\*{2,}|\.{3}|…)[^\s]{1,24}$/;
|
||||
|
||||
const keyFingerprintCache = new Map<string, string>();
|
||||
|
||||
const fnv1a64Hex = (value: string): string => {
|
||||
const cached = keyFingerprintCache.get(value);
|
||||
if (cached) return cached;
|
||||
|
||||
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV_PRIME = 0x100000001b3n;
|
||||
|
||||
let hash = FNV_OFFSET_BASIS;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= BigInt(value.charCodeAt(i));
|
||||
hash = (hash * FNV_PRIME) & 0xffffffffffffffffn;
|
||||
}
|
||||
|
||||
const hex = hash.toString(16).padStart(16, '0');
|
||||
keyFingerprintCache.set(value, hex);
|
||||
return hex;
|
||||
};
|
||||
|
||||
const looksLikeRawSecret = (text: string): boolean => {
|
||||
if (!text || /\s/.test(text)) return false;
|
||||
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.endsWith('.json')) return false;
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://')) return false;
|
||||
if (/[\\/]/.test(text)) return false;
|
||||
|
||||
if (KEY_LIKE_TOKEN_REGEX.test(text)) return true;
|
||||
|
||||
if (text.length >= 32 && text.length <= 512) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.length >= 16 && text.length < 32 && /^[A-Za-z0-9._=-]+$/.test(text)) {
|
||||
return /[A-Za-z]/.test(text) && /\d/.test(text);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const extractRawSecretFromText = (text: string): string | null => {
|
||||
if (!text) return null;
|
||||
if (looksLikeRawSecret(text)) return text;
|
||||
|
||||
const keyLikeMatch = text.match(KEY_LIKE_TOKEN_REGEX);
|
||||
if (keyLikeMatch?.[0]) return keyLikeMatch[0];
|
||||
|
||||
const queryMatch = text.match(
|
||||
/(?:[?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/i
|
||||
);
|
||||
const queryValue = queryMatch?.[2];
|
||||
if (queryValue && looksLikeRawSecret(queryValue)) {
|
||||
return queryValue;
|
||||
}
|
||||
|
||||
const headerMatch = text.match(
|
||||
/(api[-_]?key|key|token|access[-_]?token|authorization)\s*[:=]\s*([A-Za-z0-9._=-]+)/i
|
||||
);
|
||||
const headerValue = headerMatch?.[2];
|
||||
if (headerValue && looksLikeRawSecret(headerValue)) {
|
||||
return headerValue;
|
||||
}
|
||||
|
||||
const bearerMatch = text.match(/\bBearer\s+([A-Za-z0-9._=-]{6,})/i);
|
||||
const bearerValue = bearerMatch?.[1];
|
||||
if (bearerValue && looksLikeRawSecret(bearerValue)) {
|
||||
return bearerValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function normalizeUsageSourceId(
|
||||
value: unknown,
|
||||
masker: (val: string) => string = maskApiKey
|
||||
): string {
|
||||
const raw = typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
const extracted = extractRawSecretFromText(trimmed);
|
||||
if (extracted) {
|
||||
return `${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(extracted)}`;
|
||||
}
|
||||
|
||||
if (MASKED_TOKEN_HINT_REGEX.test(trimmed)) {
|
||||
return `${USAGE_SOURCE_PREFIX_MASKED}${masker(trimmed)}`;
|
||||
}
|
||||
|
||||
return `${USAGE_SOURCE_PREFIX_TEXT}${trimmed}`;
|
||||
}
|
||||
|
||||
export function buildCandidateUsageSourceIds(input: { apiKey?: string; prefix?: string }): string[] {
|
||||
const result: string[] = [];
|
||||
|
||||
const prefix = input.prefix?.trim();
|
||||
if (prefix) {
|
||||
result.push(`${USAGE_SOURCE_PREFIX_TEXT}${prefix}`);
|
||||
}
|
||||
|
||||
const apiKey = input.apiKey?.trim();
|
||||
if (apiKey) {
|
||||
result.push(`${USAGE_SOURCE_PREFIX_KEY}${fnv1a64Hex(apiKey)}`);
|
||||
result.push(`${USAGE_SOURCE_PREFIX_MASKED}${maskApiKey(apiKey)}`);
|
||||
}
|
||||
|
||||
return Array.from(new Set(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 对使用数据中的敏感字段进行遮罩
|
||||
*/
|
||||
@@ -200,6 +318,7 @@ export function collectUsageDetails(usageData: any): UsageDetail[] {
|
||||
if (detail && detail.timestamp) {
|
||||
details.push({
|
||||
...detail,
|
||||
source: normalizeUsageSourceId(detail.source),
|
||||
__modelName: modelName
|
||||
});
|
||||
}
|
||||
@@ -784,11 +903,11 @@ export function calculateStatusBarData(
|
||||
authIndexFilter?: number
|
||||
): StatusBarData {
|
||||
const BLOCK_COUNT = 20;
|
||||
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
|
||||
|
||||
const now = Date.now();
|
||||
const hourAgo = now - HOUR_MS;
|
||||
const windowStart = now - WINDOW_MS;
|
||||
|
||||
// Initialize blocks
|
||||
const blockStats: Array<{ success: number; failure: number }> = Array.from(
|
||||
@@ -802,7 +921,7 @@ export function calculateStatusBarData(
|
||||
// Filter and bucket the usage details
|
||||
usageDetails.forEach((detail) => {
|
||||
const timestamp = Date.parse(detail.timestamp);
|
||||
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) {
|
||||
if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -878,7 +997,7 @@ export function computeKeyStats(usageData: any, masker: (val: string) => string
|
||||
const details = modelEntry?.details || [];
|
||||
|
||||
details.forEach((detail: any) => {
|
||||
const source = maskUsageSensitiveValue(detail?.source, masker);
|
||||
const source = normalizeUsageSourceId(detail?.source, masker);
|
||||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
||||
const isFailed = detail?.failed === true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user