Compare commits

...

42 Commits

Author SHA1 Message Date
kongkongyo
400f722129 chore: 更新 .gitignore,移除无效注释和调整条目名称 2026-01-16 23:10:43 +08:00
kongkongyo
eda6cd361e Enhance monitoring page functionality and user experience
Changes:
- Add provider type filtering for request logs to quickly locate specific provider usage
- Optimize request log display by showing provider type in a dedicated column and removing auth index column for better readability
- Add friendly prompts for providers that don't support auto-disable (e.g., Claude, Gemini) with manual operation guides
- Point WebUI repository to this repository (forked from official for modifications)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 22:11:54 +08:00
kongkongyo
67c104407f feat(aiProviders): config search and upstream sync
Changes:
- Add global search supporting filters by API key, base URL, model name, alias, headers, etc.
- Refactor provider sections with useMemo for efficient filtering logic
- Add responsive page header layout with mobile-friendly search box
- Add empty state UI for no search results (title + description)
- i18n: add search_placeholder, search_empty_title, search_empty_desc entries
- Sync and merge latest upstream/dev branch
2026-01-16 11:02:59 +08:00
kongkongyo
3e31d1a3b4 Merge pull request #2 from router-for-me/dev
fix(api): use oauth model alias endpoints
2026-01-16 10:20:03 +08:00
hkfires
99c4fbc30d fix(api): use oauth model alias endpoints 2026-01-16 09:13:38 +08:00
kongkongyo
3c34872352 Merge remote-tracking branch 'upstream/dev' into dev 2026-01-14 18:25:40 +08:00
kongkongyo
5123d254f2 fix(monitor): fix statistics logic issues in monitor charts
Changes:
- Fix hourly token chart incorrectly counting failed request tokens
- Fix daily trend chart using UTC date causing cross-day statistics errors
- Daily trend chart now distinguishes success/failed request counts
- Token statistics only count successful requests for more accurate data

Modified files:
- src/components/monitor/DailyTrendChart.tsx (modified)
- src/components/monitor/HourlyTokenChart.tsx (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:34:37 +08:00
Supra4E8C
a44257edda fix(antigravity): enhance error handling and support multiple request bodies 2026-01-14 17:13:07 +08:00
Supra4E8C
ebb80df24a fix(quota): include project_id in antigravity quota requests 2026-01-14 16:44:36 +08:00
kongkongyo
76bfa26d3e fix(monitor): correct timezone conversion in monitor charts
Changes:
- Correct time label parsing in hourly model request distribution chart
- Correct time label parsing in hourly token usage chart
- Mark ISO time strings as UTC to enable proper browser conversion to user's local time

Modified files:
- src/components/monitor/HourlyModelChart.tsx (modified)
- src/components/monitor/HourlyTokenChart.tsx (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:07:39 +08:00
kongkongyo
9805219fe8 feat: add monitor center module
Changes:
- Add monitor center page with KPI cards, charts and request logs
- Implement channel stats, failure analysis, model distribution visualization
- Support time range filtering (last 1/6/24 hours)
- Add model disable feature with related hooks and state management
- Optimize request logs list performance with virtual scrolling
- Improve mobile styles and table scrolling experience
- Add i18n support for Chinese and English

Modified files:
- src/pages/MonitorPage.tsx (added)
- src/components/monitor/* (added, 10 components)
- src/hooks/useDisableModel.ts (added)
- src/utils/monitor.ts (added)
- src/i18n/locales/*.json (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:46:49 +08:00
LTbinglingfeng
5165715d37 fix: 调整登录页面的重定向逻辑和键盘事件处理顺序 2026-01-10 23:10:30 +08:00
Supra4E8C
73ee6eb2f3 fix(ai-providers): keep custom header editing stable in modals 2026-01-10 14:00:50 +08:00
Supra4E8C
161d5d1e7f Merge pull request #49 from sunday-ma/feature/fix-login-enter-key
fix: 添加登录表单 Enter 键提交功能
2026-01-08 19:16:48 +08:00
Sunny
3cbd04b296 Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:33 +08:00
Sunny
859f7f120c Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:18 +08:00
sunday-ma
fea29f7318 fix: 添加登录表单 Enter 键提交功能 2026-01-08 14:16:38 +08:00
Supra4E8C
f663b83ac8 feat(auth-files): normalize OAuth excluded models handling and update related API methods 2026-01-07 12:26:33 +08:00
LTbinglingfeng
ee99836285 Revert "feat(auth-files): add external migration modal for antigravity credentials"
This reverts commit 2086c348a9.
2026-01-07 00:02:45 +08:00
Supra4E8C
2086c348a9 feat(auth-files): add external migration modal for antigravity credentials 2026-01-06 18:21:34 +08:00
LTbinglingfeng
a8abf71bfe fix(settings): align log size and routing update controls 2026-01-06 00:30:06 +08:00
Supra4E8C
8dca670358 feat: add vertex provider, oauth model mappings, and routing/log settings 2026-01-05 19:03:05 +08:00
Supra4E8C
71556a51c5 fix(usage): prevent gaps in request trend fill by matching point colors 2026-01-05 17:32:01 +08:00
LTbinglingfeng
2a92ea8862 feat(AuthFilesPage): add title section with file count badge 2026-01-05 00:18:35 +08:00
LTbinglingfeng
681fc3cee5 fix(quota): cap per-page credentials to 14 2026-01-05 00:00:22 +08:00
Supra4E8C
916dd3ec26 Merge pull request #44 from moxi000/dev
feat: 优化配额管理页面 UI 与交互
2026-01-04 23:38:44 +08:00
moxi
692f7f3cde fix(quota): allow refresh without creds 2026-01-04 18:48:27 +08:00
Supra4E8C
bf20f3d86e fix(PageTransition): prevent unnecessary execution in useEffect when pathname matches 2026-01-04 18:25:54 +08:00
Supra4E8C
b7e720133d feat(auth-files): add file size validation for uploads 2026-01-04 18:14:18 +08:00
moxi
e914337e57 feat(button): enhance button component to conditionally render children
- Added a check to determine if children are present before rendering them in the button.
- Improved button rendering logic for better handling of empty or false children values.
2026-01-04 01:12:48 +08:00
moxi
6364bac1f2 feat(quota): improve refresh button functionality and update translations
- Added a new `isRefreshing` state to streamline loading logic for the refresh button.
- Updated the refresh button's disabled and loading states for better user experience.
- Simplified the refresh button content display.
- Revised translations for the refresh action in both English and Chinese locales.
- Enhanced styles for button alignment and SVG display.
2026-01-04 01:05:58 +08:00
moxi
38a3e20427 feat(quota): enhance QuotaSection with improved view mode handling and refresh functionality
- Introduced effective view mode logic to manage 'paged' and 'all' views based on file count.
- Added a warning for too many files when in 'all' view, prompting users to switch to 'paged'.
- Updated refresh button to handle loading states more effectively and provide clearer user feedback.
- Enhanced UI with new translations for view modes and refresh actions.
- Adjusted styles for better alignment and spacing in the view mode toggle and refresh button.
2026-01-04 00:45:34 +08:00
moxi
334d75f2dd fix: lint error 2026-01-04 00:04:36 +08:00
moxi
42eb783395 feat: 优化配额管理页面 UI 与交互
- 卡片布局改为 CSS Grid 自适应,最小宽度 380px,支持 1080p 下显示 4 列
- 分页控件重构:移除数字输入框,改为 [按页显示] / [显示全部] 切换按钮
- 动态计算每页数量:按页模式固定显示 3 行(行数 * 动态列数)
- Header 布局优化:凭证计数移至标题旁(淡蓝色气泡),刷新按钮合并为图标
- 安全限制:凭证数超过 30 个时禁用显示全部功能并弹窗提示
2026-01-03 22:43:58 +08:00
Supra4E8C
84b219957e Revert "style(config): allow editor wrapper to grow flexibly with min-height"
This reverts commit 1d8729ec53.
2026-01-03 15:54:48 +08:00
Supra4E8C
f5c1ef36ce fix(api-keys): validate api key charset 2026-01-03 15:51:32 +08:00
Supra4E8C
fae4fb0fed refactor(utils): simplify maskApiKey to show only 2 chars at each end 2026-01-03 15:42:34 +08:00
Supra4E8C
1d8729ec53 style(config): allow editor wrapper to grow flexibly with min-height 2026-01-03 15:30:40 +08:00
Supra4E8C
c6ef8a259f Merge branch 'dev' of https://github.com/router-for-me/Cli-Proxy-API-Management-Center into dev 2026-01-03 15:05:54 +08:00
Supra4E8C
0efef5a789 style(config): improve editor wrapper responsive height with clamp and dvh 2026-01-03 14:52:56 +08:00
LTbinglingfeng
db376c7504 fix(layout): wire header refresh to page loaders and quota config refresh 2026-01-03 01:40:54 +08:00
LTbinglingfeng
8232812ac2 feat(ui): show AIStudio models for virtual auth files and adjust Gemini OAuth spacing 2026-01-02 22:42:20 +08:00
71 changed files with 8724 additions and 1283 deletions

3
.gitignore vendored
View File

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

57
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"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",
@@ -71,6 +72,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -465,6 +467,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1865,6 +1868,33 @@
"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",
@@ -1930,6 +1960,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2017,6 +2048,7 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2334,6 +2366,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2545,6 +2578,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2809,6 +2843,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3285,6 +3320,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3614,6 +3650,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3720,6 +3757,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3737,6 +3775,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3780,9 +3819,9 @@
}
},
"node_modules/react-router": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -3802,12 +3841,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
"react-router": "7.10.1"
"react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
@@ -3845,6 +3884,7 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4027,6 +4067,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4103,6 +4144,7 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4244,6 +4286,7 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -7,12 +7,13 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"type-check": "tsc --noEmit"
},
"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

@@ -54,6 +54,7 @@ export function PageTransition({
useEffect(() => {
if (isAnimating) return;
if (location.key === currentLayerKey) return;
if (currentLayerPathname === location.pathname) return;
const scrollContainer = resolveScrollContainer();
exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0;
const resolveOrderIndex = (pathname?: string) => {
@@ -69,17 +70,27 @@ export function PageTransition({
: toIndex > fromIndex
? 'forward'
: 'backward';
setTransitionDirection(nextDirection);
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setTransitionDirection(nextDirection);
setLayers((prev) => {
const prevCurrent = prev[prev.length - 1];
return [
prevCurrent
? { ...prevCurrent, status: 'exiting' }
: { key: location.key, location, status: 'exiting' },
{ key: location.key, location, status: 'current' },
];
});
setIsAnimating(true);
});
setIsAnimating(true);
return () => {
cancelled = true;
};
}, [
isAnimating,
location,

View File

@@ -26,6 +26,7 @@ import {
IconShield,
IconSlidersHorizontal,
IconTimer,
IconActivity,
} from '@/components/ui/icons';
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
import {
@@ -36,6 +37,7 @@ import {
useThemeStore,
} from '@/stores';
import { configApi, versionApi } from '@/services/api';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
const sidebarIcons: Record<string, ReactNode> = {
dashboard: <IconLayoutDashboard size={18} />,
@@ -49,6 +51,7 @@ 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
@@ -368,6 +371,7 @@ 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) => {
@@ -384,12 +388,22 @@ export function MainLayout() {
const handleRefreshAll = async () => {
clearCache();
try {
await fetchConfig(undefined, true);
showNotification(t('notification.data_refreshed'), 'success');
} catch (error: any) {
showNotification(`${t('notification.refresh_failed')}: ${error?.message || ''}`, 'error');
const results = await Promise.allSettled([
fetchConfig(undefined, true),
triggerHeaderRefresh()
]);
const rejected = results.find((result) => result.status === 'rejected');
if (rejected && rejected.status === 'rejected') {
const reason = rejected.reason;
const message =
typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : '';
showNotification(
`${t('notification.refresh_failed')}${message ? `: ${message}` : ''}`,
'error'
);
return;
}
showNotification(t('notification.data_refreshed'), 'success');
};
const handleVersionCheck = async () => {

View File

@@ -0,0 +1,409 @@
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

@@ -0,0 +1,279 @@
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

@@ -0,0 +1,101 @@
/**
* 禁用模型确认弹窗组件
* 封装三次确认的 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

@@ -0,0 +1,420 @@
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

@@ -0,0 +1,314 @@
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

@@ -0,0 +1,274 @@
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

@@ -0,0 +1,201 @@
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

@@ -0,0 +1,205 @@
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

@@ -0,0 +1,672 @@
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

@@ -0,0 +1,158 @@
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

@@ -0,0 +1,82 @@
/**
* 不支持自动禁用提示弹窗组件
* 显示手动操作指南
*/
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

@@ -0,0 +1,8 @@
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

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function ClaudeModal({
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
@@ -95,8 +95,8 @@ export function ClaudeModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: {},
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function CodexModal({
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
@@ -95,8 +95,8 @@ export function CodexModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
@@ -17,7 +17,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: {},
headers: [],
excludedModels: [],
excludedText: '',
});
@@ -39,7 +39,7 @@ export function GeminiModal({
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: initialData.headers ?? {},
headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
@@ -91,8 +91,8 @@ export function GeminiModal({
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={headersToEntries(form.headers)}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))}
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,170 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
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 styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
import type { VertexFormState } from '../types';
import { VertexModal } from './VertexModal';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
}
export function VertexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onCloseModal,
onSave,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
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));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.vertex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.vertex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.vertex_item_title')} #{index + 1}
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.vertex_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<VertexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { VertexSection } from './VertexSection';

View File

@@ -3,6 +3,7 @@ export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar';
export * from './hooks/useProviderStats';

View File

@@ -6,6 +6,7 @@ export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
@@ -31,13 +32,22 @@ export interface AmpcodeFormState {
mappingEntries: ModelEntry[];
}
export type GeminiFormState = GeminiKeyConfig & { excludedText: string };
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
headers: HeaderEntry[];
excludedText: string;
};
export type ProviderFormState = ProviderKeyConfig & {
export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
excludedText: string;
};
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
};
export interface ProviderSectionProps<TConfig> {
configs: TConfig[];
keyStats: KeyStats;

View File

@@ -2,28 +2,30 @@
* Generic quota section component.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useQuotaStore, useThemeStore } from '@/stores';
import type { AuthFileItem, ResolvedTheme } from '@/types';
import { QuotaCard } from './QuotaCard';
import type { QuotaStatusState } from './QuotaCard';
import { useQuotaLoader } from './useQuotaLoader';
import type { QuotaConfig } from './quotaConfigs';
import { useGridColumns } from './useGridColumns';
import { IconRefreshCw } from '@/components/ui/icons';
import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaSetter<T> = (updater: QuotaUpdater<T>) => void;
const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
type ViewMode = 'paged' | 'all';
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
const MAX_ITEMS_PER_PAGE = 14;
const MAX_SHOW_ALL_THRESHOLD = 30;
interface QuotaPaginationState<T> {
pageSize: number;
@@ -40,7 +42,7 @@ interface QuotaPaginationState<T> {
const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginationState<T> => {
const [page, setPage] = useState(1);
const [pageSize, setPageSizeState] = useState(() => clampCardPageSize(defaultPageSize));
const [pageSize, setPageSizeState] = useState(defaultPageSize);
const [loading, setLoadingState] = useState(false);
const [loadingScope, setLoadingScope] = useState<'page' | 'all' | null>(null);
@@ -57,7 +59,7 @@ const useQuotaPagination = <T,>(items: T[], defaultPageSize = 6): QuotaPaginatio
}, [items, currentPage, pageSize]);
const setPageSize = useCallback((size: number) => {
setPageSizeState(clampCardPageSize(size));
setPageSizeState(size);
setPage(1);
}, []);
@@ -107,10 +109,17 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
Record<string, TState>
>;
/* Removed useRef */
const [columns, gridRef] = useGridColumns(380); // Min card width 380px matches SCSS
const [viewMode, setViewMode] = useState<ViewMode>('paged');
const [showTooManyWarning, setShowTooManyWarning] = useState(false);
const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [
files,
config.filterFn
config
]);
const showAllAllowed = filteredFiles.length <= MAX_SHOW_ALL_THRESHOLD;
const effectiveViewMode: ViewMode = viewMode === 'all' && !showAllAllowed ? 'paged' : viewMode;
const {
pageSize,
@@ -121,19 +130,59 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
goToPrev,
goToNext,
loading: sectionLoading,
loadingScope,
setLoading
} = useQuotaPagination(filteredFiles);
useEffect(() => {
if (showAllAllowed) return;
if (viewMode !== 'all') return;
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setViewMode('paged');
setShowTooManyWarning(true);
});
return () => {
cancelled = true;
};
}, [showAllAllowed, viewMode]);
// Update page size based on view mode and columns
useEffect(() => {
if (effectiveViewMode === 'all') {
setPageSize(Math.max(1, filteredFiles.length));
} else {
// Paged mode: 3 rows * columns, capped to avoid oversized pages.
setPageSize(Math.min(columns * 3, MAX_ITEMS_PER_PAGE));
}
}, [effectiveViewMode, columns, filteredFiles.length, setPageSize]);
const { quota, loadQuota } = useQuotaLoader(config);
const handleRefreshPage = useCallback(() => {
loadQuota(pageItems, 'page', setLoading);
}, [loadQuota, pageItems, setLoading]);
const pendingQuotaRefreshRef = useRef(false);
const prevFilesLoadingRef = useRef(loading);
const handleRefreshAll = useCallback(() => {
loadQuota(filteredFiles, 'all', setLoading);
}, [loadQuota, filteredFiles, setLoading]);
const handleRefresh = useCallback(() => {
pendingQuotaRefreshRef.current = true;
void triggerHeaderRefresh();
}, []);
useEffect(() => {
const wasLoading = prevFilesLoadingRef.current;
prevFilesLoadingRef.current = loading;
if (!pendingQuotaRefreshRef.current) return;
if (loading) return;
if (!wasLoading) return;
pendingQuotaRefreshRef.current = false;
const scope = effectiveViewMode === 'all' ? 'all' : 'page';
const targets = effectiveViewMode === 'all' ? filteredFiles : pageItems;
if (targets.length === 0) return;
loadQuota(targets, scope, setLoading);
}, [loading, effectiveViewMode, filteredFiles, pageItems, loadQuota, setLoading]);
useEffect(() => {
if (loading) return;
@@ -153,28 +202,56 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
});
}, [filteredFiles, loading, setQuota]);
const titleNode = (
<div className={styles.titleWrapper}>
<span>{t(`${config.i18nPrefix}.title`)}</span>
{filteredFiles.length > 0 && (
<span className={styles.countBadge}>
{filteredFiles.length}
</span>
)}
</div>
);
const isRefreshing = sectionLoading || loading;
return (
<Card
title={t(`${config.i18nPrefix}.title`)}
title={titleNode}
extra={
<div className={styles.headerActions}>
<div className={styles.viewModeToggle}>
<Button
variant={effectiveViewMode === 'paged' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setViewMode('paged')}
>
{t('auth_files.view_mode_paged')}
</Button>
<Button
variant={effectiveViewMode === 'all' ? 'primary' : 'secondary'}
size="sm"
onClick={() => {
if (filteredFiles.length > MAX_SHOW_ALL_THRESHOLD) {
setShowTooManyWarning(true);
} else {
setViewMode('all');
}
}}
>
{t('auth_files.view_mode_all')}
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshPage}
disabled={disabled || sectionLoading || pageItems.length === 0}
loading={sectionLoading && loadingScope === 'page'}
onClick={handleRefresh}
disabled={disabled || isRefreshing}
loading={isRefreshing}
title={t('quota_management.refresh_files_and_quota')}
aria-label={t('quota_management.refresh_files_and_quota')}
>
{t(`${config.i18nPrefix}.refresh_button`)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRefreshAll}
disabled={disabled || sectionLoading || filteredFiles.length === 0}
loading={sectionLoading && loadingScope === 'all'}
>
{t(`${config.i18nPrefix}.fetch_all`)}
{!isRefreshing && <IconRefreshCw size={16} />}
</Button>
</div>
}
@@ -186,31 +263,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/>
) : (
<>
<div className={config.controlsClassName}>
<div className={config.controlClassName}>
<label>{t('auth_files.page_size_label')}</label>
<input
className={styles.pageSizeSelect}
type="number"
min={MIN_CARD_PAGE_SIZE}
max={MAX_CARD_PAGE_SIZE}
step={1}
value={pageSize}
onChange={(e) => {
const value = e.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) return;
setPageSize(value);
}}
/>
</div>
<div className={config.controlClassName}>
<label>{t('common.info')}</label>
<div className={styles.statsInfo}>
{filteredFiles.length} {t('auth_files.files_count')}
</div>
</div>
</div>
<div className={config.gridClassName}>
<div ref={gridRef} className={config.gridClassName}>
{pageItems.map((item) => (
<QuotaCard
key={item.name}
@@ -224,7 +277,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
/>
))}
</div>
{filteredFiles.length > pageSize && (
{filteredFiles.length > pageSize && effectiveViewMode === 'paged' && (
<div className={styles.pagination}>
<Button
variant="secondary"
@@ -253,6 +306,16 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
)}
</>
)}
{showTooManyWarning && (
<div className={styles.warningOverlay} onClick={() => setShowTooManyWarning(false)}>
<div className={styles.warningModal} onClick={(e) => e.stopPropagation()}>
<p>{t('auth_files.too_many_files_warning')}</p>
<Button variant="primary" size="sm" onClick={() => setShowTooManyWarning(false)}>
{t('common.confirm')}
</Button>
</div>
</div>
)}
</Card>
);
}

View File

@@ -18,7 +18,7 @@ import type {
GeminiCliQuotaBucketState,
GeminiCliQuotaState
} from '@/types';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
@@ -55,6 +55,8 @@ type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
@@ -82,6 +84,43 @@ export interface QuotaConfig<TState, TData> {
renderQuotaItems: (quota: TState, t: TFunction, helpers: QuotaRenderHelpers) => ReactNode;
}
const resolveAntigravityProjectId = async (file: AuthFileItem): Promise<string> => {
try {
const text = await authFilesApi.downloadText(file.name);
const trimmed = text.trim();
if (!trimmed) return DEFAULT_ANTIGRAVITY_PROJECT_ID;
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevel = normalizeStringValue(parsed.project_id ?? parsed.projectId);
if (topLevel) return topLevel;
const installed =
parsed.installed && typeof parsed.installed === 'object' && parsed.installed !== null
? (parsed.installed as Record<string, unknown>)
: null;
const installedProjectId = installed
? normalizeStringValue(installed.project_id ?? installed.projectId)
: null;
if (installedProjectId) return installedProjectId;
const web =
parsed.web && typeof parsed.web === 'object' && parsed.web !== null
? (parsed.web as Record<string, unknown>)
: null;
const webProjectId = web ? normalizeStringValue(web.project_id ?? web.projectId) : null;
if (webProjectId) return webProjectId;
} catch {
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
}
return DEFAULT_ANTIGRAVITY_PROJECT_ID;
};
const isAntigravityUnknownFieldError = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes('unknown name') && normalized.includes('cannot find field');
};
const fetchAntigravityQuota = async (
file: AuthFileItem,
t: TFunction
@@ -92,52 +131,64 @@ const fetchAntigravityQuota = async (
throw new Error(t('antigravity_quota.missing_auth_index'));
}
const projectId = await resolveAntigravityProjectId(file);
const requestBodies = [JSON.stringify({ projectId }), JSON.stringify({ project: projectId })];
let lastError = '';
let lastStatus: number | undefined;
let priorityStatus: number | undefined;
let hadSuccess = false;
for (const url of ANTIGRAVITY_QUOTA_URLS) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: '{}'
});
for (let attempt = 0; attempt < requestBodies.length; attempt++) {
try {
const result = await apiCallApi.request({
authIndex,
method: 'POST',
url,
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
data: requestBodies[attempt]
});
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
if (result.statusCode < 200 || result.statusCode >= 300) {
lastError = getApiCallErrorMessage(result);
lastStatus = result.statusCode;
if (result.statusCode === 403 || result.statusCode === 404) {
priorityStatus ??= result.statusCode;
}
if (
result.statusCode === 400 &&
isAntigravityUnknownFieldError(lastError) &&
attempt < requestBodies.length - 1
) {
continue;
}
break;
}
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
hadSuccess = true;
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
const models = payload?.models;
if (!models || typeof models !== 'object' || Array.isArray(models)) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
if (groups.length === 0) {
lastError = t('antigravity_quota.empty_models');
continue;
}
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
return groups;
} catch (err: unknown) {
lastError = err instanceof Error ? err.message : t('common.unknown_error');
const status = getStatusFromError(err);
if (status) {
lastStatus = status;
if (status === 403 || status === 404) {
priorityStatus ??= status;
}
}
}
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook to calculate the number of grid columns based on container width and item min-width.
* Returns [columns, refCallback].
*/
export function useGridColumns(
itemMinWidth: number,
gap: number = 16
): [number, (node: HTMLDivElement | null) => void] {
const [columns, setColumns] = useState(1);
const [element, setElement] = useState<HTMLDivElement | null>(null);
const refCallback = useCallback((node: HTMLDivElement | null) => {
setElement(node);
}, []);
useEffect(() => {
if (!element) return;
const updateColumns = () => {
const containerWidth = element.clientWidth;
const effectiveItemWidth = itemMinWidth + gap;
const count = Math.floor((containerWidth + gap) / effectiveItemWidth);
setColumns(Math.max(1, count));
};
updateColumns();
const observer = new ResizeObserver(() => {
updateColumns();
});
observer.observe(element);
return () => observer.disconnect();
}, [element, itemMinWidth, gap]);
return [columns, refCallback];
}

View File

@@ -20,6 +20,7 @@ export function Button({
disabled,
...rest
}: PropsWithChildren<ButtonProps>) {
const hasChildren = children !== null && children !== undefined && children !== false;
const classes = [
'btn',
`btn-${variant}`,
@@ -33,7 +34,7 @@ export function Button({
return (
<button className={classes} disabled={disabled || loading} {...rest}>
{loading && <span className="loading-spinner" aria-hidden="true" />}
<span>{children}</span>
{hasChildren && <span>{children}</span>}
</button>
);
}

View File

@@ -2,16 +2,20 @@ import type { PropsWithChildren, ReactNode } from 'react';
interface CardProps {
title?: ReactNode;
subtitle?: ReactNode;
extra?: ReactNode;
className?: string;
}
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
export function Card({ title, subtitle, extra, children, className }: PropsWithChildren<CardProps>) {
return (
<div className={className ? `card ${className}` : 'card'}>
{(title || extra) && (
<div className="card-header">
<div className="title">{title}</div>
<div className="card-title-group">
<div className="title">{title}</div>
{subtitle && <div className="subtitle">{subtitle}</div>}
</div>
{extra}
</div>
)}

View File

@@ -54,19 +54,28 @@ export function Modal({ open, title, onClose, footer, width = 520, children }: P
);
useEffect(() => {
let cancelled = false;
if (open) {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsVisible(true);
setIsClosing(false);
return;
queueMicrotask(() => {
if (cancelled) return;
setIsVisible(true);
setIsClosing(false);
});
} else if (isVisible) {
queueMicrotask(() => {
if (cancelled) return;
startClose(false);
});
}
if (isVisible) {
startClose(false);
}
return () => {
cancelled = true;
};
}, [open, isVisible, startClose]);
const handleClose = useCallback(() => {

View File

@@ -314,3 +314,11 @@ 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

@@ -8,3 +8,6 @@ export { useLocalStorage } from './useLocalStorage';
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

@@ -0,0 +1,199 @@
/**
* 禁用模型 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

@@ -0,0 +1,24 @@
import { useEffect } from 'react';
export type HeaderRefreshHandler = () => void | Promise<void>;
let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null;
export const triggerHeaderRefresh = async () => {
if (!activeHeaderRefreshHandler) return;
await activeHeaderRefreshHandler();
};
export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null) => {
useEffect(() => {
if (!handler) return;
activeHeaderRefreshHandler = handler;
return () => {
if (activeHeaderRefreshHandler === handler) {
activeHeaderRefreshHandler = null;
}
};
}, [handler]);
};

View File

@@ -95,7 +95,8 @@
"usage_stats": "Usage Statistics",
"config_management": "Config Management",
"logs": "Logs Viewer",
"system_info": "Management Center Info"
"system_info": "Management Center Info",
"monitor": "Monitor Center"
},
"dashboard": {
"title": "Dashboard",
@@ -137,11 +138,22 @@
"usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file",
"logs_max_total_size_title": "Log Size Limit",
"logs_max_total_size_label": "Total log size cap (MB):",
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
"logs_max_total_size_update": "Update",
"request_log_title": "Request Logging",
"request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"force_model_prefix_enable": "Force model prefix",
"ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*"
"ws_auth_enable": "Require auth for /ws/*",
"routing_title": "Routing Strategy",
"routing_strategy_label": "Routing strategy:",
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
"routing_strategy_update": "Update",
"routing_strategy_round_robin": "round-robin (cycle)",
"routing_strategy_fill_first": "fill-first (prioritize)"
},
"api_keys": {
"title": "API Keys Management",
@@ -221,6 +233,27 @@
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count",
"vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration",
"vertex_empty_desc": "Click the button above to add the first configuration",
"vertex_item_title": "Vertex Configuration",
"vertex_add_modal_title": "Add Vertex API Configuration",
"vertex_add_modal_key_label": "API Key:",
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
"vertex_add_modal_url_label": "Base URL (Required):",
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Edit Vertex API Configuration",
"vertex_edit_modal_key_label": "API Key:",
"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_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
"vertex_models_count": "Mapping count",
"ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -292,7 +325,10 @@
"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"
"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"
},
"auth_files": {
"title": "Auth Files Management",
@@ -312,6 +348,7 @@
"delete_all_confirm": "Are you sure you want to delete all auth files? This operation cannot be undone!",
"delete_filtered_confirm": "Are you sure you want to delete all {{type}} auth files? This operation cannot be undone!",
"upload_error_json": "Only JSON files are allowed",
"upload_error_size": "File size cannot exceed {{maxSize}}",
"upload_success": "File uploaded successfully",
"download_success": "File downloaded successfully",
"delete_success": "File deleted successfully",
@@ -327,6 +364,9 @@
"search_placeholder": "Filter by name, type, or provider",
"page_size_label": "Per page",
"page_size_unit": "items",
"view_mode_paged": "Paged",
"view_mode_all": "Show all",
"too_many_files_warning": "Too many credentials. Showing all may cause performance issues, please use paged view.",
"filter_all": "All",
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
@@ -464,6 +504,34 @@
"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",
"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",
"save": "Save/Update",
"save_success": "Model mappings updated",
"save_failed": "Failed to update model mappings",
"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.",
"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."
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login",
@@ -548,7 +616,7 @@
"iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;",
"iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login",
@@ -709,7 +777,8 @@
"quota_management": {
"title": "Quota Management",
"description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.",
"refresh_files": "Refresh auth files"
"refresh_files": "Refresh auth files",
"refresh_files_and_quota": "Refresh files & quota"
},
"system_info": {
"title": "Management Center Info",
@@ -761,12 +830,16 @@
"quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated",
"logs_max_total_size_updated": "Log size limit updated",
"request_log_updated": "Request logging setting updated",
"force_model_prefix_updated": "Model prefix setting updated",
"ws_auth_updated": "WebSocket authentication setting updated",
"routing_strategy_updated": "Routing strategy updated",
"login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully",
"api_key_deleted": "API key deleted successfully",
"api_key_invalid_chars": "API key can only contain letters, numbers, and symbols",
"gemini_key_added": "Gemini key added successfully",
"gemini_key_updated": "Gemini key updated successfully",
"gemini_key_deleted": "Gemini key deleted successfully",
@@ -780,6 +853,10 @@
"claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully",
"vertex_config_added": "Vertex configuration added successfully",
"vertex_config_updated": "Vertex configuration updated successfully",
"vertex_config_deleted": "Vertex configuration deleted successfully",
"vertex_base_url_required": "Please enter the Vertex Base URL",
"config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty",
@@ -833,5 +910,170 @@
"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,7 +95,8 @@
"usage_stats": "使用统计",
"config_management": "配置管理",
"logs": "日志查看",
"system_info": "中心信息"
"system_info": "中心信息",
"monitor": "监控中心"
},
"dashboard": {
"title": "仪表盘",
@@ -137,11 +138,22 @@
"usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件",
"logs_max_total_size_title": "日志容量限制",
"logs_max_total_size_label": "日志总大小上限 (MB):",
"logs_max_total_size_hint": "设置为 0 表示不限制。",
"logs_max_total_size_update": "更新",
"request_log_title": "请求日志",
"request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"force_model_prefix_enable": "强制模型前缀",
"ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权"
"ws_auth_enable": "启用 /ws/* 鉴权",
"routing_title": "路由策略",
"routing_strategy_label": "路由策略:",
"routing_strategy_hint": "round-robin 为轮询fill-first 为优先填充。",
"routing_strategy_update": "更新",
"routing_strategy_round_robin": "round-robin (轮询)",
"routing_strategy_fill_first": "fill-first (优先填充)"
},
"api_keys": {
"title": "API 密钥管理",
@@ -221,6 +233,27 @@
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量",
"vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置",
"vertex_empty_desc": "点击上方按钮添加第一个配置",
"vertex_item_title": "Vertex配置",
"vertex_add_modal_title": "添加Vertex API配置",
"vertex_add_modal_key_label": "API密钥:",
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
"vertex_add_modal_url_label": "Base URL (必填):",
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
"vertex_add_modal_proxy_label": "代理 URL (可选):",
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "编辑Vertex API配置",
"vertex_edit_modal_key_label": "API密钥:",
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。",
"vertex_models_count": "映射数量",
"ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL",
@@ -292,7 +325,10 @@
"openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型"
"openai_test_select_empty": "当前未配置模型,请先添加模型",
"search_placeholder": "搜索配置(密钥、地址、模型等)",
"search_empty_title": "没有匹配的配置",
"search_empty_desc": "请尝试更换关键字或清空搜索框"
},
"auth_files": {
"title": "认证文件管理",
@@ -312,6 +348,7 @@
"delete_all_confirm": "确定要删除所有认证文件吗?此操作不可恢复!",
"delete_filtered_confirm": "确定要删除筛选出的 {{type}} 认证文件吗?此操作不可恢复!",
"upload_error_json": "只能上传JSON文件",
"upload_error_size": "文件大小不能超过 {{maxSize}}",
"upload_success": "文件上传成功",
"download_success": "文件下载成功",
"delete_success": "文件删除成功",
@@ -327,6 +364,9 @@
"search_placeholder": "输入名称、类型或提供方关键字",
"page_size_label": "单页数量",
"page_size_unit": "个/页",
"view_mode_paged": "按页显示",
"view_mode_all": "显示全部",
"too_many_files_warning": "您的凭证总数过多,全部加载会导致页面卡顿,请保持单页浏览。",
"filter_all": "全部",
"filter_qwen": "Qwen",
"filter_gemini": "Gemini",
@@ -464,6 +504,34 @@
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"oauth_model_mappings": {
"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": "添加映射",
"save": "保存/更新",
"save_success": "模型映射已更新",
"save_failed": "更新模型映射失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
"delete_success": "已删除该提供商的模型映射",
"delete_failed": "删除模型映射失败",
"no_models": "未配置模型映射",
"model_count": "映射 {{count}} 条模型",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": {
"codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录",
@@ -548,7 +616,7 @@
"iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie例如 sessionid=...;",
"iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录",
@@ -709,7 +777,8 @@
"quota_management": {
"title": "配额管理",
"description": "集中查看 OAuth 额度与剩余情况",
"refresh_files": "刷新认证文件"
"refresh_files": "刷新认证文件",
"refresh_files_and_quota": "刷新认证文件&额度"
},
"system_info": {
"title": "管理中心信息",
@@ -761,12 +830,16 @@
"quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新",
"logs_max_total_size_updated": "日志容量设置已更新",
"request_log_updated": "请求日志设置已更新",
"force_model_prefix_updated": "模型前缀设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新",
"routing_strategy_updated": "路由策略已更新",
"login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功",
"api_key_deleted": "API密钥删除成功",
"api_key_invalid_chars": "API密钥仅支持英文字母、数字和符号",
"gemini_key_added": "Gemini密钥添加成功",
"gemini_key_updated": "Gemini密钥更新成功",
"gemini_key_deleted": "Gemini密钥删除成功",
@@ -780,6 +853,10 @@
"claude_config_added": "Claude配置添加成功",
"claude_config_updated": "Claude配置更新成功",
"claude_config_deleted": "Claude配置删除成功",
"vertex_config_added": "Vertex配置添加成功",
"vertex_config_updated": "Vertex配置更新成功",
"vertex_config_deleted": "Vertex配置删除成功",
"vertex_base_url_required": "请填写Vertex Base URL",
"config_enabled": "配置已启用",
"config_disabled": "配置已停用",
"field_required": "必填字段不能为空",
@@ -833,5 +910,170 @@
"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,7 +20,53 @@
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 $spacing-xl 0;
margin: 0;
}
.pageHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.searchBox {
flex: 0 1 320px;
min-width: 200px;
@include mobile {
flex: 1 1 100%;
}
:global(.form-group) {
margin-bottom: 0;
}
}
.searchEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl * 2;
text-align: center;
background: var(--bg-secondary);
border: 1px dashed var(--border-primary);
border-radius: 12px;
}
.searchEmptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: $spacing-sm;
}
.searchEmptyDesc {
font-size: 14px;
color: var(--text-tertiary);
}
.content {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { entriesToModels } from '@/components/ui/ModelInputList';
import {
@@ -7,21 +7,24 @@ import {
CodexSection,
GeminiSection,
OpenAISection,
VertexSection,
useProviderStats,
type GeminiFormState,
type OpenAIFormState,
type ProviderFormState,
type ProviderModal,
type VertexFormState,
} from '@/components/providers';
import {
parseExcludedModels,
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';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject } from '@/utils/headers';
import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() {
@@ -41,16 +44,139 @@ export function AiProvidersPage() {
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [saving, setSaving] = useState(false);
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) => {
@@ -63,17 +189,32 @@ export function AiProvidersPage() {
setLoading(true);
setError('');
try {
const data = await fetchConfig();
const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value;
setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []);
setVertexConfigs(data?.vertexApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []);
try {
const ampcode = await ampcodeApi.getAmpcode();
updateConfigValue('ampcode', ampcode);
if (vertexResult.status === 'fulfilled') {
setVertexConfigs(vertexResult.value || []);
updateConfigValue('vertex-api-key', vertexResult.value || []);
clearCache('vertex-api-key');
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode');
} catch {
// ignore
}
} catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed');
@@ -92,11 +233,13 @@ export function AiProvidersPage() {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [
config?.geminiApiKeys,
config?.codexApiKeys,
config?.claudeApiKeys,
config?.vertexApiKeys,
config?.openaiCompatibility,
]);
@@ -112,6 +255,10 @@ export function AiProvidersPage() {
setModal({ type, index });
};
const openVertexModal = (index: number | null) => {
setModal({ type: 'vertex', index });
};
const openAmpcodeModal = () => {
setModal({ type: 'ampcode', index: null });
};
@@ -127,7 +274,7 @@ export function AiProvidersPage() {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)),
headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
@@ -283,7 +430,7 @@ export function AiProvidersPage() {
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)),
headers: buildHeaderObject(form.headers),
models: entriesToModels(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText),
};
@@ -351,6 +498,72 @@ export function AiProvidersPage() {
}
};
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (!baseUrl) {
showNotification(t('notification.vertex_base_url_required'), 'error');
return;
}
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean) as ProviderKeyConfig['models'],
};
const nextList =
editIndex !== null
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
: [...vertexConfigs, payload];
await providersApi.saveVertexConfigs(nextList);
setVertexConfigs(nextList);
updateConfigValue('vertex-api-key', nextList);
clearCache('vertex-api-key');
const message =
editIndex !== null
? t('notification.vertex_config_updated')
: t('notification.vertex_config_added');
showNotification(message, 'success');
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
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');
}
};
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
setSaving(true);
try {
@@ -412,99 +625,176 @@ export function AiProvidersPage() {
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
const vertexModalIndex = modal?.type === 'vertex' ? modal.index : null;
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
return (
<div className={styles.container}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('ai_providers.title')}</h1>
<div className={styles.searchBox}>
<Input
type="text"
placeholder={t('ai_providers.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className={styles.content}>
{error && <div className="error-box">{error}</div>}
<GeminiSection
configs={geminiKeys}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={geminiModalIndex}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(index)}
onDelete={deleteGemini}
onToggle={(index, enabled) => void setConfigEnabled('gemini', index, enabled)}
onCloseModal={closeModal}
onSave={saveGemini}
/>
{normalizedQuery && !hasSearchResults && (
<div className={styles.searchEmpty}>
<div className={styles.searchEmptyTitle}>{t('ai_providers.search_empty_title')}</div>
<div className={styles.searchEmptyDesc}>{t('ai_providers.search_empty_desc')}</div>
</div>
)}
<CodexSection
configs={codexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={codexModalIndex}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', index)}
onDelete={(index) => void deleteProviderEntry('codex', index)}
onToggle={(index, enabled) => void setConfigEnabled('codex', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
{filteredGeminiKeys.length > 0 && (
<GeminiSection
configs={filteredGeminiKeys.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'gemini'}
modalIndex={
geminiModalIndex !== null
? filteredGeminiKeys.findIndex(({ originalIndex }) => originalIndex === geminiModalIndex)
: null
}
onAdd={() => openGeminiModal(null)}
onEdit={(index) => openGeminiModal(filteredGeminiKeys[index]?.originalIndex ?? index)}
onDelete={(index) => deleteGemini(filteredGeminiKeys[index]?.originalIndex ?? index)}
onToggle={(index, enabled) =>
void setConfigEnabled('gemini', filteredGeminiKeys[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={saveGemini}
/>
)}
<ClaudeSection
configs={claudeConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'claude'}
modalIndex={claudeModalIndex}
onAdd={() => openProviderModal('claude', null)}
onEdit={(index) => openProviderModal('claude', index)}
onDelete={(index) => void deleteProviderEntry('claude', index)}
onToggle={(index, enabled) => void setConfigEnabled('claude', index, enabled)}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/>
{filteredCodexConfigs.length > 0 && (
<CodexSection
configs={filteredCodexConfigs.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'codex'}
modalIndex={
codexModalIndex !== null
? filteredCodexConfigs.findIndex(({ originalIndex }) => originalIndex === codexModalIndex)
: null
}
onAdd={() => openProviderModal('codex', null)}
onEdit={(index) => openProviderModal('codex', filteredCodexConfigs[index]?.originalIndex ?? index)}
onDelete={(index) =>
void deleteProviderEntry('codex', filteredCodexConfigs[index]?.originalIndex ?? index)
}
onToggle={(index, enabled) =>
void setConfigEnabled('codex', filteredCodexConfigs[index]?.originalIndex ?? index, enabled)
}
onCloseModal={closeModal}
onSave={(form, editIndex) => saveProvider('codex', form, editIndex)}
/>
)}
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
{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)}
/>
)}
<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}
/>
{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}
/>
)}
{showAmpcode && (
<AmpcodeSection
config={config?.ampcode}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isBusy={ampcodeBusy}
isModalOpen={modal?.type === 'ampcode'}
onOpen={openAmpcodeModal}
onCloseModal={closeModal}
onBusyChange={setAmpcodeBusy}
/>
)}
{filteredOpenaiProviders.length > 0 && (
<OpenAISection
configs={filteredOpenaiProviders.map(({ item }) => item)}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
resolvedTheme={resolvedTheme}
isModalOpen={modal?.type === 'openai'}
modalIndex={
openaiModalIndex !== null
? filteredOpenaiProviders.findIndex(({ originalIndex }) => originalIndex === openaiModalIndex)
: null
}
onAdd={() => openOpenaiModal(null)}
onEdit={(index) => openOpenaiModal(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onDelete={(index) => deleteOpenai(filteredOpenaiProviders[index]?.originalIndex ?? index)}
onCloseModal={closeModal}
onSave={saveOpenai}
/>
)}
</div>
</div>
);

View File

@@ -9,6 +9,7 @@ import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { apiKeysApi } from '@/services/api';
import { maskApiKey } from '@/utils/format';
import { isValidApiKeyCharset } from '@/utils/validation';
import styles from './ApiKeysPage.module.scss';
export function ApiKeysPage() {
@@ -83,6 +84,10 @@ export function ApiKeysPage() {
showNotification(`${t('notification.please_enter')} ${t('notification.api_key')}`, 'error');
return;
}
if (!isValidApiKeyCharset(trimmed)) {
showNotification(t('notification.api_key_invalid_chars'), 'error');
return;
}
const isEdit = editingIndex !== null;
const nextKeys = isEdit

View File

@@ -32,6 +32,28 @@
flex-wrap: wrap;
}
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
}
.errorBox {
padding: $spacing-md;
background-color: rgba(239, 68, 68, 0.1);
@@ -134,19 +156,6 @@
}
}
.statsInfo {
padding: 8px 12px;
background-color: var(--bg-secondary);
border-radius: $radius-md;
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
height: 38px;
box-sizing: border-box;
display: flex;
align-items: center;
}
// 卡片网格
.fileGrid {
display: grid;
@@ -742,6 +751,32 @@
}
}
// OAuth 模型映射表单
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
@include mobile {
grid-template-columns: 1fr;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingFork {
display: flex;
align-items: center;
}
// 详情弹窗
.detailContent {
max-height: 400px;

File diff suppressed because it is too large Load Diff

View File

@@ -133,14 +133,18 @@
.editorWrapper {
width: 100%;
flex: 1;
min-height: 800px;
flex: 0 0 auto;
height: clamp(360px, 60vh, 920px);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
overflow: hidden;
position: relative;
--floating-controls-height: 0px;
@supports (height: 100dvh) {
height: clamp(360px, 60dvh, 920px);
}
// Floating search toolbar on top of the editor (but not covering content).
.floatingControls {
position: absolute;
@@ -219,8 +223,8 @@
.configCard {
display: flex;
flex-direction: column;
height: 1120px;
flex-shrink: 0;
flex: 1;
min-height: 0;
overflow: visible;
}
@@ -253,11 +257,6 @@
}
.configCard {
height: 880px;
padding: $spacing-md;
}
.editorWrapper {
min-height: 600px;
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
@@ -50,11 +50,6 @@ export function LoginPage() {
init();
}, [detectedBase, restoreSession, storedBase, storedKey, storedRememberPassword]);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
const handleSubmit = async () => {
if (!managementKey.trim()) {
setError(t('login.error_required'));
@@ -81,6 +76,21 @@ export function LoginPage() {
}
};
const handleSubmitKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
},
[loading, handleSubmit]
);
if (isAuthenticated) {
const redirect = (location.state as any)?.from?.pathname || '/';
return <Navigate to={redirect} replace />;
}
return (
<div className="login-page">
<div className="login-card">
@@ -129,11 +139,13 @@ export function LoginPage() {
)}
<Input
autoFocus
label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'}
value={managementKey}
onChange={(e) => setManagementKey(e.target.value)}
onKeyDown={handleSubmitKeyDown}
rightElement={
<button
type="button"

View File

@@ -16,6 +16,7 @@ import {
IconTrash2,
IconX,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { logsApi } from '@/services/api/logs';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
@@ -474,6 +475,8 @@ export function LogsPage() {
}
};
useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return;
try {

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -115,6 +115,13 @@
margin-top: $spacing-sm;
}
.geminiProjectField {
:global(.form-group) {
margin-top: $spacing-sm;
gap: $spacing-sm;
}
}
.filePicker {
display: flex;
align-items: center;

View File

@@ -327,19 +327,21 @@ export function OAuthPage() {
>
<div className="hint">{t(provider.hintKey)}</div>
{provider.id === 'gemini-cli' && (
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
<div className={styles.geminiProjectField}>
<Input
label={t('auth_login.gemini_cli_project_id_label')}
hint={t('auth_login.gemini_cli_project_id_hint')}
value={state.projectId || ''}
error={state.projectIdError}
onChange={(e) =>
updateProviderState(provider.id, {
projectId: e.target.value,
projectIdError: undefined
})
}
placeholder={t('auth_login.gemini_cli_project_id_placeholder')}
/>
</div>
)}
{state.url && (
<div className={`connection-box ${styles.authUrlBox}`}>

View File

@@ -30,6 +30,37 @@
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
align-items: center;
:global(.btn-sm) {
line-height: 16px;
}
:global(svg) {
display: block;
}
}
.titleWrapper {
display: flex;
align-items: center;
gap: $spacing-sm;
line-height: 24px;
}
.countBadge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
min-width: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
color: var(--count-badge-text);
background-color: var(--count-badge-bg);
box-sizing: border-box;
}
.errorBox {
@@ -76,11 +107,7 @@
.geminiCliGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(3, minmax(0, 1fr));
@include tablet {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
@include mobile {
grid-template-columns: 1fr;
@@ -112,28 +139,28 @@
}
}
.viewModeToggle {
display: flex;
gap: $spacing-xs;
align-items: center;
}
.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 {
@@ -331,3 +358,32 @@
background-color: var(--bg-secondary);
border-radius: $radius-md;
}
.warningOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.warningModal {
background-color: var(--bg-primary);
border-radius: $radius-lg;
padding: $spacing-lg;
max-width: 400px;
text-align: center;
box-shadow: $shadow-lg;
p {
margin: 0 0 $spacing-md 0;
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
}
}

View File

@@ -4,9 +4,9 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
@@ -26,6 +26,15 @@ export function QuotaPage() {
const disableControls = connectionStatus !== 'connected';
const loadConfig = useCallback(async () => {
try {
await configFileApi.fetchConfigYaml();
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError((prev) => prev || errorMessage);
}
}, [t]);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
@@ -40,20 +49,22 @@ export function QuotaPage() {
}
}, [t]);
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadConfig(), loadFiles()]);
}, [loadConfig, loadFiles]);
useHeaderRefresh(handleHeaderRefresh);
useEffect(() => {
loadFiles();
}, [loadFiles]);
loadConfig();
}, [loadFiles, loadConfig]);
return (
<div className={styles.container}>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
<p className={styles.description}>{t('quota_management.description')}</p>
<div className={styles.headerActions}>
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
{t('quota_management.refresh_files')}
</Button>
</div>
</div>
{error && <div className={styles.errorBox}>{error}</div>}

View File

@@ -122,6 +122,33 @@
}
}
.retryRowAligned {
align-items: flex-start;
.retryButton {
margin-top: calc(1.5em + #{$spacing-xs});
}
@include mobile {
align-items: stretch;
.retryButton {
margin-top: 0;
}
}
}
.retryRowInputGrow {
:global(.form-group) {
flex: 1 1 0;
min-width: 0;
}
.retryInput {
width: 100%;
}
}
.retryInput {
width: 140px;

View File

@@ -13,6 +13,9 @@ type PendingKey =
| 'debug'
| 'proxy'
| 'retry'
| 'logsMaxSize'
| 'forceModelPrefix'
| 'routingStrategy'
| 'switchProject'
| 'switchPreview'
| 'usage'
@@ -31,6 +34,8 @@ export function SettingsPage() {
const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0);
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState('');
@@ -41,9 +46,34 @@ export function SettingsPage() {
setLoading(true);
setError('');
try {
const data = (await fetchConfig()) as Config;
const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
fetchConfig(),
configApi.getLogsMaxTotalSizeMb(),
configApi.getForceModelPrefix(),
configApi.getRoutingStrategy(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value as Config;
setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
}
if (prefixResult.status === 'fulfilled') {
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
}
if (routingResult.status === 'fulfilled' && routingResult.value) {
setRoutingStrategy(String(routingResult.value));
updateConfigValue('routing/strategy', String(routingResult.value));
}
} catch (err: any) {
setError(err?.message || t('notification.refresh_failed'));
} finally {
@@ -52,7 +82,7 @@ export function SettingsPage() {
};
load();
}, [fetchConfig, t]);
}, [fetchConfig, t, updateConfigValue]);
useEffect(() => {
if (config) {
@@ -60,8 +90,14 @@ export function SettingsPage() {
if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry);
}
if (typeof config.logsMaxTotalSizeMb === 'number') {
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
}
if (config.routingStrategy) {
setRoutingStrategy(config.routingStrategy);
}
}
}, [config?.proxyUrl, config?.requestRetry]);
}, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value }));
@@ -69,7 +105,7 @@ export function SettingsPage() {
const toggleSetting = async (
section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth',
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
value: boolean,
updater: (val: boolean) => Promise<any>,
successMessage: string
@@ -84,6 +120,8 @@ export function SettingsPage() {
return config?.loggingToFile ?? false;
case 'ws-auth':
return config?.wsAuth ?? false;
case 'force-model-prefix':
return config?.forceModelPrefix ?? false;
default:
return false;
}
@@ -162,6 +200,52 @@ export function SettingsPage() {
}
};
const handleLogsMaxTotalSizeUpdate = async () => {
const previous = config?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(logsMaxTotalSizeMb);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setLogsMaxTotalSizeMb(previous);
return;
}
const normalized = Math.max(0, parsed);
setPendingFlag('logsMaxSize', true);
updateConfigValue('logs-max-total-size-mb', normalized);
try {
await configApi.updateLogsMaxTotalSizeMb(normalized);
clearCache('logs-max-total-size-mb');
showNotification(t('notification.logs_max_total_size_updated'), 'success');
} catch (err: any) {
setLogsMaxTotalSizeMb(previous);
updateConfigValue('logs-max-total-size-mb', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('logsMaxSize', false);
}
};
const handleRoutingStrategyUpdate = async () => {
const strategy = routingStrategy.trim();
if (!strategy) {
showNotification(t('login.error_invalid'), 'error');
return;
}
const previous = config?.routingStrategy ?? 'round-robin';
setPendingFlag('routingStrategy', true);
updateConfigValue('routing/strategy', strategy);
try {
await configApi.updateRoutingStrategy(strategy);
clearCache('routing/strategy');
showNotification(t('notification.routing_strategy_updated'), 'success');
} catch (err: any) {
setRoutingStrategy(previous);
updateConfigValue('routing/strategy', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('routingStrategy', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
@@ -171,63 +255,78 @@ export function SettingsPage() {
<div className={styles.grid}>
<Card>
{error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading}
onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
}
/>
{error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch
label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading}
onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
}
/>
<ToggleSwitch
label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading}
onChange={(value) =>
toggleSetting(
'usage',
'usage-statistics-enabled',
value,
configApi.updateUsageStatistics,
t('notification.usage_statistics_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading}
onChange={(value) =>
toggleSetting(
'usage',
'usage-statistics-enabled',
value,
configApi.updateUsageStatistics,
t('notification.usage_statistics_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) =>
toggleSetting(
'loggingToFile',
'logging-to-file',
value,
configApi.updateLoggingToFile,
t('notification.logging_to_file_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) =>
toggleSetting(
'loggingToFile',
'logging-to-file',
value,
configApi.updateLoggingToFile,
t('notification.logging_to_file_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading}
onChange={(value) =>
toggleSetting(
'wsAuth',
'ws-auth',
value,
configApi.updateWsAuth,
t('notification.ws_auth_updated')
)
}
/>
</div>
</Card>
<ToggleSwitch
label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading}
onChange={(value) =>
toggleSetting(
'wsAuth',
'ws-auth',
value,
configApi.updateWsAuth,
t('notification.ws_auth_updated')
)
}
/>
<ToggleSwitch
label={t('basic_settings.force_model_prefix_enable')}
checked={config?.forceModelPrefix ?? false}
disabled={disableControls || pending.forceModelPrefix || loading}
onChange={(value) =>
toggleSetting(
'forceModelPrefix',
'force-model-prefix',
value,
configApi.updateForceModelPrefix,
t('notification.force_model_prefix_updated')
)
}
/>
</div>
</Card>
<Card title={t('basic_settings.proxy_title')}>
<Input
@@ -271,6 +370,57 @@ export function SettingsPage() {
</div>
</Card>
<Card title={t('basic_settings.logs_max_total_size_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<Input
label={t('basic_settings.logs_max_total_size_label')}
hint={t('basic_settings.logs_max_total_size_hint')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={logsMaxTotalSizeMb}
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleLogsMaxTotalSizeUpdate}
loading={pending.logsMaxSize}
disabled={disableControls || loading}
>
{t('basic_settings.logs_max_total_size_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.routing_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<div className="form-group">
<label>{t('basic_settings.routing_strategy_label')}</label>
<select
className="input"
value={routingStrategy}
onChange={(e) => setRoutingStrategy(e.target.value)}
disabled={disableControls || loading}
>
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
</select>
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
</div>
<Button
className={styles.retryButton}
onClick={handleRoutingStrategyUpdate}
loading={pending.routingStrategy}
disabled={disableControls || loading}
>
{t('basic_settings.routing_strategy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch

View File

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

View File

@@ -14,6 +14,7 @@ import {
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import {
StatCards,
@@ -63,6 +64,8 @@ export function UsagePage() {
importing
} = useUsageData();
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9;

View File

@@ -10,6 +10,7 @@ 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 /> },
@@ -24,6 +25,7 @@ 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,6 +4,44 @@
import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types';
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;
if (!source || typeof source !== 'object') return {};
const result: Record<string, string[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
const key = String(provider ?? '')
.trim()
.toLowerCase();
if (!key) return;
const rawList = Array.isArray(models)
? models
: typeof models === 'string'
? models.split(/[\n,]+/)
: [];
const seen = new Set<string>();
const normalized: string[] = [];
rawList.forEach((item) => {
const trimmed = String(item ?? '').trim();
if (!trimmed) return;
const modelKey = trimmed.toLowerCase();
if (seen.has(modelKey)) return;
seen.add(modelKey);
normalized.push(trimmed);
});
result[key] = normalized;
});
return result;
};
export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
@@ -18,11 +56,18 @@ export const authFilesApi = {
deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),
downloadText: async (name: string): Promise<string> => {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = response.data as Blob;
return blob.text();
},
// OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models');
const payload = (data && (data['oauth-excluded-models'] ?? data.items ?? data)) as any;
return payload && typeof payload === 'object' ? payload : {};
return normalizeOauthExcludedModels(data);
},
saveOauthExcludedModels: (provider: string, models: string[]) =>
@@ -31,6 +76,40 @@ export const authFilesApi = {
deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
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;
},
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-alias', { channel, mappings }),
deleteOauthModelMappings: (channel: string) =>
apiClient.delete(`/oauth-model-alias?channel=${encodeURIComponent(channel)}`),
// 获取认证凭证支持的模型
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)}`);

View File

@@ -68,8 +68,48 @@ export const configApi = {
*/
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
/**
* 获取日志总大小上限MB
*/
async getLogsMaxTotalSizeMb(): Promise<number> {
const data = await apiClient.get('/logs-max-total-size-mb');
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
},
/**
* 更新日志总大小上限MB
*/
updateLogsMaxTotalSizeMb: (value: number) =>
apiClient.put('/logs-max-total-size-mb', { value }),
/**
* WebSocket 鉴权开关
*/
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
/**
* 获取强制模型前缀开关
*/
async getForceModelPrefix(): Promise<boolean> {
const data = await apiClient.get('/force-model-prefix');
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
},
/**
* 更新强制模型前缀开关
*/
updateForceModelPrefix: (enabled: boolean) => apiClient.put('/force-model-prefix', { value: enabled }),
/**
* 获取路由策略
*/
async getRoutingStrategy(): Promise<string> {
const data = await apiClient.get('/routing/strategy');
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
},
/**
* 更新路由策略
*/
updateRoutingStrategy: (strategy: string) => apiClient.put('/routing/strategy', { value: strategy }),
};

View File

@@ -61,6 +61,30 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
return payload;
};
const serializeVertexModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
const name = typeof model?.name === 'string' ? model.name.trim() : '';
const alias = typeof model?.alias === 'string' ? model.alias.trim() : '';
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean)
: undefined;
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
const models = serializeVertexModelAliases(config.models);
if (models && models.length) payload.models = models;
return payload;
};
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
@@ -140,6 +164,22 @@ export const providersApi = {
deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
saveVertexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/vertex-api-key', configs.map((item) => serializeVertexKey(item))),
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;
@@ -154,5 +194,14 @@ export const providersApi = {
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
deleteOpenAIProvider: (name: string) =>
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
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 });
}
};

View File

@@ -258,7 +258,15 @@ export const normalizeConfigResponse = (raw: any): Config => {
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
const routing = raw.routing;
if (routing && typeof routing === 'object') {
config.routingStrategy = routing.strategy ?? routing['strategy'];
} else {
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
}
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
@@ -282,6 +290,13 @@ export const normalizeConfigResponse = (raw: any): Config => {
.filter(Boolean) as ProviderKeyConfig[];
}
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
if (Array.isArray(vertexList)) {
config.vertexApiKeys = vertexList
.map((item: any) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList

View File

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

View File

@@ -38,12 +38,16 @@ const SECTION_KEYS: RawConfigSection[] = [
'usage-statistics-enabled',
'request-log',
'logging-to-file',
'logs-max-total-size-mb',
'ws-auth',
'force-model-prefix',
'routing/strategy',
'api-keys',
'ampcode',
'gemini-api-key',
'codex-api-key',
'claude-api-key',
'vertex-api-key',
'openai-compatibility',
'oauth-excluded-models'
];
@@ -65,8 +69,14 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.requestLog;
case 'logging-to-file':
return config.loggingToFile;
case 'logs-max-total-size-mb':
return config.logsMaxTotalSizeMb;
case 'ws-auth':
return config.wsAuth;
case 'force-model-prefix':
return config.forceModelPrefix;
case 'routing/strategy':
return config.routingStrategy;
case 'api-keys':
return config.apiKeys;
case 'ampcode':
@@ -77,6 +87,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.codexApiKeys;
case 'claude-api-key':
return config.claudeApiKeys;
case 'vertex-api-key':
return config.vertexApiKeys;
case 'openai-compatibility':
return config.openaiCompatibility;
case 'oauth-excluded-models':
@@ -194,9 +206,18 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'logging-to-file':
nextConfig.loggingToFile = value;
break;
case 'logs-max-total-size-mb':
nextConfig.logsMaxTotalSizeMb = value;
break;
case 'ws-auth':
nextConfig.wsAuth = value;
break;
case 'force-model-prefix':
nextConfig.forceModelPrefix = value;
break;
case 'routing/strategy':
nextConfig.routingStrategy = value;
break;
case 'api-keys':
nextConfig.apiKeys = value;
break;
@@ -212,6 +233,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'claude-api-key':
nextConfig.claudeApiKeys = value;
break;
case 'vertex-api-key':
nextConfig.vertexApiKeys = value;
break;
case 'openai-compatibility':
nextConfig.openaiCompatibility = value;
break;

View File

@@ -0,0 +1,50 @@
/**
* 禁用模型状态管理
* 全局管理已禁用的模型,确保所有组件状态同步
*/
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

@@ -116,14 +116,42 @@ textarea {
.card-header {
display: flex;
align-items: center;
align-items: flex-start;
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;
}
}
}

View File

@@ -32,6 +32,9 @@
--failure-badge-text: #991b1b;
--failure-badge-border: #fca5a5;
--count-badge-bg: rgba(59, 130, 246, 0.14);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
@@ -66,6 +69,9 @@
--failure-badge-text: #fca5a5;
--failure-badge-border: #dc2626;
--count-badge-bg: rgba(59, 130, 246, 0.25);
--count-badge-text: var(--primary-active);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3);
}

View File

@@ -19,12 +19,16 @@ export interface Config {
usageStatisticsEnabled?: boolean;
requestLog?: boolean;
loggingToFile?: boolean;
logsMaxTotalSizeMb?: number;
wsAuth?: boolean;
forceModelPrefix?: boolean;
routingStrategy?: string;
apiKeys?: string[];
ampcode?: AmpcodeConfig;
geminiApiKeys?: GeminiKeyConfig[];
codexApiKeys?: ProviderKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[];
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[];
oauthExcludedModels?: Record<string, string[]>;
raw?: Record<string, any>;
@@ -38,12 +42,16 @@ export type RawConfigSection =
| 'usage-statistics-enabled'
| 'request-log'
| 'logging-to-file'
| 'logs-max-total-size-mb'
| 'ws-auth'
| 'force-model-prefix'
| 'routing/strategy'
| 'api-keys'
| 'ampcode'
| 'gemini-api-key'
| 'codex-api-key'
| 'claude-api-key'
| 'vertex-api-key'
| 'openai-compatibility'
| 'oauth-excluded-models';

View File

@@ -33,3 +33,12 @@ export interface OAuthConfig {
export interface OAuthExcludedModels {
models: string[];
}
// OAuth 模型映射
export interface OAuthModelMappingEntry {
name: string;
alias: string;
fork?: boolean;
}
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;

View File

@@ -4,16 +4,17 @@
*/
/**
* 隐藏 API Key 中间部分
* 隐藏 API Key 中间部分,仅保留前后两位
*/
export function maskApiKey(key: string, visibleChars: number = 4): string {
if (!key || key.length <= visibleChars * 2) {
return key;
export function maskApiKey(key: string): string {
if (!key) {
return '';
}
const visibleChars = 2;
const start = key.slice(0, visibleChars);
const end = key.slice(-visibleChars);
const maskedLength = Math.min(key.length - visibleChars * 2, 20);
const maskedLength = Math.max(key.length - visibleChars * 2, 1);
const masked = '*'.repeat(maskedLength);
return `${start}${masked}${end}`;

264
src/utils/monitor.ts Normal file
View File

@@ -0,0 +1,264 @@
/**
* 监控中心公共工具函数
*/
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

@@ -638,6 +638,8 @@ export interface ChartDataset {
data: number[];
borderColor: string;
backgroundColor: string | CanvasGradient | ((context: ScriptableContext<'line'>) => string | CanvasGradient);
pointBackgroundColor?: string;
pointBorderColor?: string;
fill: boolean;
tension: number;
}
@@ -743,6 +745,8 @@ export function buildChartData(
backgroundColor: shouldFill
? (ctx) => buildAreaGradient(ctx, style.borderColor, style.backgroundColor)
: style.backgroundColor,
pointBackgroundColor: style.borderColor,
pointBorderColor: style.borderColor,
fill: shouldFill,
tension: 0.35
};

View File

@@ -35,6 +35,14 @@ export function isValidApiKey(key: string): boolean {
return !/\s/.test(key);
}
/**
* 验证 API Key 字符集(仅允许 ASCII 可见字符)
*/
export function isValidApiKeyCharset(key: string): boolean {
if (!key) return false;
return /^[\x21-\x7E]+$/.test(key);
}
/**
* 验证 JSON 格式
*/