Compare commits

..

22 Commits

Author SHA1 Message Date
LTbinglingfeng
8b3c4189f1 fix(providers): use /chat/completions for OpenAI test requests 2026-01-28 00:17:31 +08:00
hkfires
db5fb0d125 refactor(i18n): rename model alias translation keys 2026-01-27 15:38:07 +08:00
hkfires
9515d88e3c feat(ui): add model checklist for oauth exclusions 2026-01-27 14:56:23 +08:00
hkfires
2bf721974b feat(auth): load model lists via /model-definitions/{channel} instead of per-file
model sources.
2026-01-27 14:27:26 +08:00
hkfires
0c53dcfa80 docs(i18n): rename model mappings to aliases in ui strings 2026-01-27 12:00:42 +08:00
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
LTbinglingfeng
2539710075 fix(status-bar): extend health monitor window to 200 minutes 2026-01-24 17:17:29 +08:00
LTbinglingfeng
6bdc87aed6 fix(quota): unify Gemini CLI quota groups (Flash/Pro series) 2026-01-24 16:35:59 +08:00
LTbinglingfeng
268b92c59b feat(ui): implement custom AutocompleteInput and refactor model mapping UI 2026-01-24 15:55:31 +08:00
LTbinglingfeng
c89bbd5098 feat(auth-files): add auth-file model suggestions for OAuth mappings 2026-01-24 15:30:45 +08:00
LTbinglingfeng
2715f44a5e fix(ui): use crossfade animation with subtle movement for page transitions 2026-01-24 14:16:58 +08:00
LTbinglingfeng
305ddef900 fix(ui): improve GSAP page transition smoothness 2026-01-24 14:03:15 +08:00
LTbinglingfeng
7e56d33bf0 feat(auth-files): add prefix/proxy_url modal editor 2026-01-24 01:24:05 +08:00
LTbinglingfeng
80daf03fa6 feat(auth-files): add per-file enable/disable toggle 2026-01-24 00:10:04 +08:00
LTbinglingfeng
883059b031 fix(auth-files): fix deleting OAuth model mappings providers 2026-01-19 23:29:11 +08:00
LTbinglingfeng
d077b5dd26 fix(ui): use fixed-length key masking and fingerprint usage sources 2026-01-19 00:41:11 +08:00
Supra4E8C
d79ccc480d fix: prevent focus loss in OAuth model mappings input 2026-01-17 15:41:56 +08:00
Supra4E8C
7b0d6dc7e9 fix: prevent async confirmation races in API key deletion 2026-01-17 15:31:35 +08:00
Supra4E8C
b8d7b8997c feat(ui): implement global ConfirmationModal to replace native window.confirm 2026-01-17 14:59:46 +08:00
Supra4E8C
0bb34ca74b fix(auth-files): send aliases for oauth model alias patch 2026-01-17 14:34:57 +08:00
62 changed files with 2330 additions and 6544 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,3 @@ settings.local.json
*.njsproj
*.sln
*.sw?
tmpclaude*
.claude
CLIProxyAPI

28
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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' : ''}`}>

View File

@@ -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) => {

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
</>
);
}

View File

@@ -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}`;
}

View File

@@ -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>
);
}

View File

@@ -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';

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 => {
@@ -55,40 +55,60 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
if (trimmed.endsWith('/chat/completions')) {
return trimmed;
}
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`;
if (trimmed.endsWith('/v1')) {
return `${trimmed.slice(0, -3)}/chat/completions`;
}
return `${trimmed}/chat/completions`;
};
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致
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 => ({

View 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>
);
}

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>{stat.requests.toLocaleString()}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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",
@@ -250,10 +249,10 @@
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model mappings (alias required):",
"vertex_models_label": "Model aliases (alias required):",
"vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
"vertex_models_count": "Mapping count",
"vertex_models_hint": "Each alias needs both the original model and the alias.",
"vertex_models_count": "Alias count",
"ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -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",
@@ -317,7 +316,7 @@
"openai_keys_count": "Keys Count",
"openai_models_count": "Models Count",
"openai_test_title": "Connection Test",
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.",
"openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.",
"openai_test_model_placeholder": "Model to test",
"openai_test_action": "Run Test",
"openai_test_running": "Sending test request...",
@@ -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",
@@ -480,8 +488,10 @@
"provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.",
"models_loading": "Loading models...",
"models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update",
"saving": "Saving...",
"save_success": "Excluded models updated",
@@ -504,33 +514,35 @@
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"oauth_model_mappings": {
"title": "OAuth Model Mappings",
"add": "Add Mapping",
"add_title": "Add provider model mappings",
"oauth_model_alias": {
"title": "OAuth Model Aliases",
"add": "Add Alias",
"add_title": "Add provider model aliases",
"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.",
"mappings_label": "Model mappings",
"mapping_name_placeholder": "Source model name",
"mapping_alias_placeholder": "Alias (required)",
"mapping_fork_label": "Keep original",
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
"add_mapping": "Add mapping",
"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. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.",
"alias_label": "Model aliases",
"alias_name_placeholder": "Source model name",
"alias_placeholder": "Alias (required)",
"alias_fork_label": "Keep original",
"add_alias": "Add alias",
"save": "Save/Update",
"save_success": "Model mappings updated",
"save_failed": "Failed to update model mappings",
"save_success": "Model aliases updated",
"save_failed": "Failed to update model aliases",
"delete": "Delete Provider",
"delete_confirm": "Delete model mappings for {{provider}}?",
"delete_success": "Model mappings removed",
"delete_failed": "Failed to delete model mappings",
"no_models": "No model mappings",
"model_count": "{{count}} mappings",
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
"delete_confirm": "Delete model aliases for {{provider}}?",
"delete_success": "Model aliases removed",
"delete_failed": "Failed to delete model aliases",
"no_models": "No model aliases",
"model_count": "{{count}} aliases",
"list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
"upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
@@ -791,9 +803,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 +922,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"
}
}
}

View File

@@ -95,8 +95,7 @@
"usage_stats": "使用统计",
"config_management": "配置管理",
"logs": "日志查看",
"system_info": "中心信息",
"monitor": "监控中心"
"system_info": "中心信息"
},
"dashboard": {
"title": "仪表盘",
@@ -250,10 +249,10 @@
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):",
"vertex_models_label": "模型别名 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。",
"vertex_models_count": "映射数量",
"vertex_models_hint": "每条别名需要填写原模型与别名。",
"vertex_models_count": "别名数量",
"ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -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 KeyBearer与自定义请求头。",
"openai_models_fetch_button": "从 /models 获取",
"openai_models_fetch_title": "从 /models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API KeyBearer与自定义请求头。",
"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": "返回编辑",
@@ -317,7 +316,7 @@
"openai_keys_count": "密钥数量",
"openai_models_count": "模型数量",
"openai_test_title": "连通性测试",
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。",
"openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
"openai_test_model_placeholder": "选择或输入要测试的模型",
"openai_test_action": "发送测试",
"openai_test_running": "正在发送测试请求...",
@@ -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 额度",
@@ -480,8 +488,10 @@
"provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。",
"models_loading": "正在加载模型列表...",
"models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新",
"saving": "正在保存...",
"save_success": "排除列表已更新",
@@ -504,33 +514,35 @@
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"oauth_model_mappings": {
"title": "OAuth 模型映射",
"add": "新增映射",
"add_title": "新增提供商模型映射",
"oauth_model_alias": {
"title": "OAuth 模型别名",
"add": "新增别名",
"add_title": "新增提供商模型别名",
"provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"mappings_label": "模型映射",
"mapping_name_placeholder": "原模型名称",
"mapping_alias_placeholder": "别名 (必填)",
"mapping_fork_label": "保留原名",
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"add_mapping": "添加映射",
"model_source_loading": "正在加载模型列表...",
"model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
"model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"alias_label": "模型别名",
"alias_name_placeholder": "原模型名称",
"alias_placeholder": "别名 (必填)",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新",
"save_success": "模型映射已更新",
"save_failed": "更新模型映射失败",
"save_success": "模型别名已更新",
"save_failed": "更新模型别名失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
"delete_success": "已删除该提供商的模型映射",
"delete_failed": "删除模型映射失败",
"no_models": "未配置模型映射",
"model_count": "映射 {{count}} 条模型",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
"delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
"delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型别名失败",
"no_models": "未配置模型别名",
"model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
"upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。"
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
@@ -791,9 +803,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 +922,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": "我知道了"
}
}
}

View File

@@ -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 {

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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);
@@ -455,6 +446,16 @@
}
}
.fileCardDisabled {
opacity: 0.6;
&:hover {
transform: none;
box-shadow: none;
border-color: var(--border-color);
}
}
.cardHeader {
display: flex;
align-items: center;
@@ -546,7 +547,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 +600,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;
@@ -916,3 +995,53 @@
border: 1px solid var(--danger-color);
flex-shrink: 0;
}
// 排除模型勾选列表
.excludedCheckList {
display: flex;
flex-direction: column;
gap: $spacing-xs;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-sm;
background-color: var(--bg-secondary);
}
.excludedCheckItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
transition: background-color $transition-fast;
&:hover {
background-color: var(--bg-hover);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
}
}
.excludedCheckLabel {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--text-primary);
word-break: break-all;
}
.excludedCheckDisplayName {
font-size: 12px;
color: var(--text-tertiary);
font-family: inherit;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -456,6 +456,18 @@
word-break: break-all;
}
.requestCountCell {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.requestBreakdown {
color: var(--text-secondary);
white-space: nowrap;
}
// Pricing Section (80%比例)
.pricingSection {
display: flex;

View File

@@ -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 /> },
];

View File

@@ -4,12 +4,22 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types';
import type { OAuthModelAliasEntry } 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,61 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
return result;
};
const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAliasEntry[]> => {
if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source =
record['oauth-model-alias'] ??
record.items ??
payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelAliasEntry[]> = {};
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 aliasEntry = entry as OAuthModelAliasEntry;
const dedupeKey = `${aliasEntry.name.toLowerCase()}::${aliasEntry.alias.toLowerCase()}::${aliasEntry.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey);
return true;
}) as OAuthModelAliasEntry[];
if (normalized.length) {
result[key] = normalized;
}
});
return result;
};
const OAUTH_MODEL_ALIAS_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);
@@ -79,40 +141,45 @@ export const authFilesApi = {
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
// 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;
// OAuth 模型别名
async getOauthModelAlias(): Promise<Record<string, OAuthModelAliasEntry[]>> {
const data = await apiClient.get(OAUTH_MODEL_ALIAS_ENDPOINT);
return normalizeOauthModelAlias(data);
},
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-alias', { channel, mappings }),
saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
},
deleteOauthModelMappings: (channel: string) =>
apiClient.delete(`/oauth-model-alias?channel=${encodeURIComponent(channel)}`),
deleteOauthModelAlias: async (channel: string) => {
const normalizedChannel = String(channel ?? '')
.trim()
.toLowerCase();
try {
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
} catch (err: unknown) {
const status = getStatusCode(err);
if (status !== 405) throw err;
await apiClient.delete(`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
}
},
// 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
if (!normalizedChannel) return [];
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
}
};

View File

@@ -20,12 +20,21 @@ 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`;
};
const buildV1ModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return '';
return `${normalized}/v1/models`;
};
export const modelsApi = {
/**
* Fetch available models from /v1/models endpoint (for system info page)
*/
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
const endpoint = buildModelsEndpoint(baseUrl);
const endpoint = buildV1ModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
@@ -42,6 +51,9 @@ export const modelsApi = {
return normalizeModelList(payload, { dedupe: true });
},
/**
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
*/
async fetchModelsViaApiCall(
baseUrl: string,
apiKey?: string,

View File

@@ -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)}`)
};

View File

@@ -9,4 +9,3 @@ export { useAuthStore } from './useAuthStore';
export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useDisabledModelsStore } from './useDisabledModelsStore';

View File

@@ -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() });
},
}));

View File

@@ -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
}
}));
}
}));

View File

@@ -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 {

View File

@@ -34,11 +34,11 @@ export interface OAuthExcludedModels {
models: string[];
}
// OAuth 模型映射
export interface OAuthModelMappingEntry {
// OAuth 模型别名
export interface OAuthModelAliasEntry {
name: string;
alias: string;
fork?: boolean;
}
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;
export type OAuthModelAlias = Record<string, OAuthModelAliasEntry[]>;

View File

@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
export interface GeminiCliQuotaGroupDefinition {
id: string;
label: string;
preferredModelId?: string;
modelIds: string[];
}

View File

@@ -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}`;

View File

@@ -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;
}

View File

@@ -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,
};
};

View File

@@ -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',
};

View File

@@ -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
});
}
@@ -460,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
model: string;
requests: number;
successCount: number;
failureCount: number;
tokens: number;
cost: number;
}> {
@@ -467,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
return [];
}
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>();
const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
const models = apiData?.models || {};
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 };
const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
existing.requests += modelData.total_requests || 0;
existing.tokens += modelData.total_tokens || 0;
const details = Array.isArray(modelData.details) ? modelData.details : [];
const price = modelPrices[modelName];
if (price) {
const details = Array.isArray(modelData.details) ? modelData.details : [];
const hasExplicitCounts =
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
if (hasExplicitCounts) {
existing.successCount += Number(modelData.success_count) || 0;
existing.failureCount += Number(modelData.failure_count) || 0;
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
details.forEach((detail: any) => {
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
if (!hasExplicitCounts) {
if (detail?.failed === true) {
existing.failureCount += 1;
} else {
existing.successCount += 1;
}
}
if (price) {
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
}
});
}
modelMap.set(modelName, existing);
@@ -784,11 +924,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 +942,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 +1018,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;